Compare commits

...

24 Commits

Author SHA1 Message Date
Petr Mironychev
c4e34bb3d9 chore: Update plugin to 0.9.15 version 2026-05-21 10:49:34 +02:00
Petr Mironychev
b9e0b5a00c fix: Change getting focus to chat in editor and name of title 2026-05-21 10:48:11 +02:00
Petr Mironychev
3f4bda51cd chore: Update plugin to 0.9.14 version 2026-05-19 09:48:42 +02:00
Petr Mironychev
7483c78777 feat: Add skills feature for tool and chat calling (#351) 2026-05-19 09:46:50 +02:00
Petr Mironychev
a3ad314cd4 feat: Add qt-docs mcp server 2026-05-17 21:40:48 +02:00
Petr Mironychev
74c899c8c3 fix: Add checking model support for tool calling (#350) 2026-05-17 21:27:18 +02:00
Petr Mironychev
6addcedfd0 feat: Add chat to editor view and refactor current openning 2026-05-15 23:44:43 +02:00
Petr Mironychev
eb7fc2f7b4 fix: Copy button can flow in expanded codeblock 2026-05-15 22:07:40 +02:00
Petr Mironychev
a06320d1c4 fix: LM Studio url and endpoints 2026-05-15 21:55:07 +02:00
Petr Mironychev
b1ca6823b8 fix: Throwing focus and hotkeys to QtCreator 2026-05-15 21:27:45 +02:00
Petr Mironychev
cc2d42f6d7 refactor: Move out InputTokenCounter, FileEditController, ChatHistoryStore, ChatConfigurationController 2026-05-15 20:17:22 +02:00
Petr Mironychev
4faeb90dc0 fix: Add missing files 2026-05-15 11:02:32 +02:00
Petr Mironychev
9f7497d15c refactor: Move out agent roles from Chat root view controller 2026-05-15 11:00:49 +02:00
Petr Mironychev
cab2f0a55e feat: Add too for reading original message history after compressing 2026-05-15 10:47:39 +02:00
Petr Mironychev
7704bffd88 feat: Add caching Claude provider 2026-05-15 10:28:21 +02:00
Petr Mironychev
3b421f60af refactor: Update token usage api (#347)
* refactor: Improve token usage api
* refactor: Image recognition to tokens
2026-05-14 21:19:12 +02:00
Petr Mironychev
86f4635080 feat: Add support QtCreator 19.0.2 (#346) 2026-05-14 09:33:49 +02:00
Petr Mironychev
f21757b9b3 Add paypal account 2026-05-12 15:15:09 +02:00
Ivan Lebedev
9bb6d55687 refactor: Uses single QML engine for all QML code (#323)
* refactor: Uses single QML engine for all QML code

* fix: Adds missing `QPointer` include

* Parents QML engine instance to plugin instance.

---------

Co-authored-by: Ivan Lebedev <ilebedev@flightpath3d.com>
Co-authored-by: Ivan Lebedev <ilebedev1988@gmail.com>
2026-04-28 16:33:03 +02:00
Petr Mironychev
bbb9c47cbb chore: Update plugin to 0.9.13 version 2026-04-27 07:12:52 +02:00
Petr Mironychev
46aa53e726 feat: Add llama.cpp modellisting and thinking support 2026-04-26 23:06:54 +02:00
Petr Mironychev
4d320bc065 refactor: Remove legacy code (#344) 2026-04-25 18:48:28 +02:00
Petr Mironychev
7b4e08859c feat: Add trying to parse ollama tools result when it's codeblock 2026-04-25 09:25:14 +02:00
Petr Mironychev
d15b46825e feat: Add support QtCreator 19.0.1 (#343) 2026-04-25 09:13:07 +02:00
136 changed files with 5237 additions and 4901 deletions

View File

@@ -50,8 +50,8 @@ jobs:
qt_creator_version: "18.0.2"
}
- {
qt_version: "6.10.2",
qt_creator_version: "19.0.0"
qt_version: "6.10.3",
qt_creator_version: "19.0.2"
}
steps:

View File

@@ -35,6 +35,7 @@ add_definitions(
)
add_subdirectory(sources/external/llmqore)
add_subdirectory(sources/skills)
add_subdirectory(pluginllmcore)
add_subdirectory(settings)
add_subdirectory(logger)
@@ -64,6 +65,7 @@ add_qtc_plugin(QodeAssist
QtCreator::CPlusPlus
LLMQore
PluginLLMCore
Skills
QodeAssistChatViewplugin
SOURCES
.github/workflows/build_cmake.yml
@@ -81,8 +83,6 @@ add_qtc_plugin(QodeAssist
templates/OpenAI.hpp
templates/MistralAI.hpp
templates/StarCoder2Fim.hpp
# templates/DeepSeekCoderFim.hpp
# templates/CustomFimTemplate.hpp
templates/Qwen25CoderFIM.hpp
templates/OpenAICompatible.hpp
templates/Llama3.hpp
@@ -95,6 +95,7 @@ add_qtc_plugin(QodeAssist
templates/Qwen3CoderFIM.hpp
templates/OpenAIResponses.hpp
providers/Providers.hpp
providers/ProviderUrlUtils.hpp
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
providers/OllamaCompatProvider.hpp providers/OllamaCompatProvider.cpp
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
@@ -107,15 +108,6 @@ add_qtc_plugin(QodeAssist
providers/GoogleAIProvider.hpp providers/GoogleAIProvider.cpp
providers/LlamaCppProvider.hpp providers/LlamaCppProvider.cpp
providers/CodestralProvider.hpp providers/CodestralProvider.cpp
providers/OpenAIResponses/ModelRequest.hpp
providers/OpenAIResponses/ResponseObject.hpp
providers/OpenAIResponses/GetResponseRequest.hpp
providers/OpenAIResponses/DeleteResponseRequest.hpp
providers/OpenAIResponses/CancelResponseRequest.hpp
providers/OpenAIResponses/ListInputItemsRequest.hpp
providers/OpenAIResponses/InputTokensRequest.hpp
providers/OpenAIResponses/ItemTypesReference.hpp
providers/OpenAIResponsesRequestBuilder.hpp
providers/OpenAIResponsesProvider.hpp providers/OpenAIResponsesProvider.cpp
QodeAssist.qrc
LSPCompletion.hpp
@@ -125,6 +117,9 @@ add_qtc_plugin(QodeAssist
QodeAssistClient.hpp QodeAssistClient.cpp
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
chat/ChatDocument.hpp chat/ChatDocument.cpp
chat/ChatEditor.hpp chat/ChatEditor.cpp
chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp
ConfigurationManager.hpp ConfigurationManager.cpp
CodeHandler.hpp CodeHandler.cpp
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
@@ -157,6 +152,8 @@ add_qtc_plugin(QodeAssist
tools/ReadFileTool.hpp tools/ReadFileTool.cpp
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
tools/TodoTool.hpp tools/TodoTool.cpp
tools/ReadOriginalHistoryTool.hpp tools/ReadOriginalHistoryTool.cpp
tools/SkillTool.hpp tools/SkillTool.cpp
mcp/McpServerManager.hpp mcp/McpServerManager.cpp
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp

View File

@@ -0,0 +1,123 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentRoleController.hpp"
#include <utils/aspects.h>
#include "AgentRole.hpp"
#include "ChatAssistantSettings.hpp"
#include "GeneralSettings.hpp"
namespace QodeAssist::Chat {
AgentRoleController::AgentRoleController(QObject *parent)
: QObject(parent)
{
connect(
&Settings::chatAssistantSettings().systemPrompt,
&Utils::BaseAspect::changed,
this,
&AgentRoleController::baseSystemPromptChanged);
loadAvailableRoles();
}
QStringList AgentRoleController::availableRoles() const
{
return m_availableRoles;
}
QString AgentRoleController::currentRole() const
{
return m_currentRole;
}
QString AgentRoleController::baseSystemPrompt() const
{
return Settings::chatAssistantSettings().systemPrompt();
}
QString AgentRoleController::currentRoleDescription() const
{
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
if (lastRoleId.isEmpty())
return Settings::AgentRolesManager::getNoRole().description;
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
if (role.id.isEmpty())
return Settings::AgentRolesManager::getNoRole().description;
return role.description;
}
QString AgentRoleController::currentRoleSystemPrompt() const
{
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
if (lastRoleId.isEmpty())
return QString();
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
if (role.id.isEmpty())
return QString();
return role.systemPrompt;
}
void AgentRoleController::loadAvailableRoles()
{
const QList<Settings::AgentRole> roles = Settings::AgentRolesManager::loadAllRoles();
m_availableRoles.clear();
m_availableRoles.append(Settings::AgentRolesManager::getNoRole().name);
for (const auto &role : roles)
m_availableRoles.append(role.name);
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
m_currentRole = Settings::AgentRolesManager::getNoRole().name;
if (!lastRoleId.isEmpty()) {
for (const auto &role : roles) {
if (role.id == lastRoleId) {
m_currentRole = role.name;
break;
}
}
}
emit availableRolesChanged();
emit currentRoleChanged();
}
void AgentRoleController::applyRole(const QString &roleName)
{
auto &settings = Settings::chatAssistantSettings();
if (roleName == Settings::AgentRolesManager::getNoRole().name) {
settings.lastUsedRoleId.setValue("");
settings.writeSettings();
m_currentRole = roleName;
emit currentRoleChanged();
return;
}
const QList<Settings::AgentRole> roles = Settings::AgentRolesManager::loadAllRoles();
for (const auto &role : roles) {
if (role.name == roleName) {
settings.lastUsedRoleId.setValue(role.id);
settings.writeSettings();
m_currentRole = role.name;
emit currentRoleChanged();
break;
}
}
}
void AgentRoleController::openSettings()
{
Settings::showSettings(Utils::Id("QodeAssist.AgentRoles"));
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,38 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QStringList>
namespace QodeAssist::Chat {
class AgentRoleController : public QObject
{
Q_OBJECT
public:
explicit AgentRoleController(QObject *parent = nullptr);
QStringList availableRoles() const;
QString currentRole() const;
QString baseSystemPrompt() const;
QString currentRoleDescription() const;
QString currentRoleSystemPrompt() const;
void loadAvailableRoles();
void applyRole(const QString &roleName);
void openSettings();
signals:
void availableRolesChanged();
void currentRoleChanged();
void baseSystemPromptChanged();
private:
QStringList m_availableRoles;
QString m_currentRole;
};
} // namespace QodeAssist::Chat

View File

@@ -23,6 +23,7 @@ qt_add_qml_module(QodeAssistChatView
qml/controls/FileMentionPopup.qml
qml/controls/FileEditsActionBar.qml
qml/controls/ContextViewer.qml
qml/controls/SkillCommandPopup.qml
qml/controls/Toast.qml
qml/controls/TopBar.qml
qml/controls/SplitDropZone.qml
@@ -43,9 +44,11 @@ qt_add_qml_module(QodeAssistChatView
icons/window-unlock.svg
icons/chat-icon.svg
icons/chat-pause-icon.svg
icons/new-chat-icon.svg
icons/rules-icon.svg
icons/context-icon.svg
icons/open-in-editor.svg
icons/open-in-window.svg
icons/apply-changes-button.svg
icons/undo-changes-button.svg
icons/reject-changes-button.svg
@@ -69,7 +72,13 @@ qt_add_qml_module(QodeAssistChatView
FileItem.hpp FileItem.cpp
ChatFileManager.hpp ChatFileManager.cpp
ChatCompressor.hpp ChatCompressor.cpp
AgentRoleController.hpp AgentRoleController.cpp
ChatConfigurationController.hpp ChatConfigurationController.cpp
FileEditController.hpp FileEditController.cpp
InputTokenCounter.hpp InputTokenCounter.cpp
ChatHistoryStore.hpp ChatHistoryStore.cpp
FileMentionItem.hpp FileMentionItem.cpp
SessionFileRegistry.hpp SessionFileRegistry.cpp
)
target_link_libraries(QodeAssistChatView
@@ -86,8 +95,9 @@ target_link_libraries(QodeAssistChatView
QodeAssistUIControlsplugin
QodeAssistLogger
LLMQore
Skills
)
target_include_directories(QodeAssistChatView
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}
)

View File

@@ -228,6 +228,8 @@ bool ChatCompressor::createCompressedChatFile(
summaryMessage["images"] = QJsonArray();
root["messages"] = QJsonArray{summaryMessage};
root["compressedFrom"] = sourcePath;
root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate);
if (QFile::exists(destPath))
QFile::remove(destPath);

View File

@@ -0,0 +1,99 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatConfigurationController.hpp"
#include <utils/aspects.h>
#include "ConfigurationManager.hpp"
#include "GeneralSettings.hpp"
namespace QodeAssist::Chat {
ChatConfigurationController::ChatConfigurationController(QObject *parent)
: QObject(parent)
{
auto &settings = Settings::generalSettings();
connect(
&settings.caProvider,
&Utils::BaseAspect::changed,
this,
&ChatConfigurationController::updateCurrentConfiguration);
connect(
&settings.caModel,
&Utils::BaseAspect::changed,
this,
&ChatConfigurationController::updateCurrentConfiguration);
loadAvailableConfigurations();
}
QStringList ChatConfigurationController::availableConfigurations() const
{
return m_availableConfigurations;
}
QString ChatConfigurationController::currentConfiguration() const
{
return m_currentConfiguration;
}
void ChatConfigurationController::updateCurrentConfiguration()
{
auto &settings = Settings::generalSettings();
m_currentConfiguration
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
emit currentConfigurationChanged();
}
void ChatConfigurationController::loadAvailableConfigurations()
{
auto &manager = Settings::ConfigurationManager::instance();
manager.loadConfigurations(Settings::ConfigurationType::Chat);
QVector<Settings::AIConfiguration> configs = manager.configurations(
Settings::ConfigurationType::Chat);
m_availableConfigurations.clear();
m_availableConfigurations.append(QObject::tr("Current Settings"));
for (const Settings::AIConfiguration &config : configs) {
m_availableConfigurations.append(config.name);
}
updateCurrentConfiguration();
emit availableConfigurationsChanged();
}
void ChatConfigurationController::applyConfiguration(const QString &configName)
{
if (configName == QObject::tr("Current Settings")) {
return;
}
auto &manager = Settings::ConfigurationManager::instance();
QVector<Settings::AIConfiguration> configs = manager.configurations(
Settings::ConfigurationType::Chat);
for (const Settings::AIConfiguration &config : configs) {
if (config.name == configName) {
auto &settings = Settings::generalSettings();
settings.caProvider.setValue(config.provider);
settings.caModel.setValue(config.model);
settings.caTemplate.setValue(config.templateName);
settings.caUrl.setValue(config.url);
settings.caCustomEndpoint.setValue(config.customEndpoint);
settings.writeSettings();
m_currentConfiguration = QString("%1 - %2").arg(config.provider, config.model);
emit currentConfigurationChanged();
break;
}
}
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,35 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QStringList>
namespace QodeAssist::Chat {
class ChatConfigurationController : public QObject
{
Q_OBJECT
public:
explicit ChatConfigurationController(QObject *parent = nullptr);
QStringList availableConfigurations() const;
QString currentConfiguration() const;
void loadAvailableConfigurations();
void applyConfiguration(const QString &configName);
signals:
void availableConfigurationsChanged();
void currentConfigurationChanged();
private:
void updateCurrentConfiguration();
QStringList m_availableConfigurations;
QString m_currentConfiguration;
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,228 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatHistoryStore.hpp"
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QRegularExpression>
#include <QUrl>
#include <coreplugin/icore.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include "ChatModel.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
namespace QodeAssist::Chat {
ChatHistoryStore::ChatHistoryStore(ChatModel *chatModel, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
{}
QString ChatHistoryStore::historyDir() const
{
QString path;
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
Settings::ProjectSettings projectSettings(project);
path = projectSettings.chatHistoryPath().toFSPathString();
} else {
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
path = baseDir.filePath("qodeassist/chat_history");
}
QDir dir(path);
if (!dir.exists() && !dir.mkpath(".")) {
LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
return QString();
}
return path;
}
QString ChatHistoryStore::suggestedFileName() const
{
QString shortMessage;
if (m_chatModel->rowCount() > 0) {
QString firstMessage
= m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString();
shortMessage = firstMessage.split('\n').first().simplified().left(30);
if (shortMessage.isEmpty()) {
QVariantList images
= m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
if (!images.isEmpty()) {
shortMessage = "image_chat";
}
}
}
return generateChatFileName(shortMessage, historyDir());
}
QString ChatHistoryStore::autosaveFilePath(const QString &recentFilePath) const
{
if (!recentFilePath.isEmpty()) {
return recentFilePath;
}
QString dir = historyDir();
if (dir.isEmpty()) {
return QString();
}
return QDir(dir).filePath(suggestedFileName() + ".json");
}
QString ChatHistoryStore::autosaveFilePath(
const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const
{
if (!recentFilePath.isEmpty()) {
return recentFilePath;
}
QString dir = historyDir();
if (dir.isEmpty()) {
return QString();
}
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
if (shortMessage.isEmpty() && hasImageAttachments) {
shortMessage = "image_chat";
}
QString fileName = generateChatFileName(shortMessage, dir);
return QDir(dir).filePath(fileName + ".json");
}
SerializationResult ChatHistoryStore::save(const QString &filePath) const
{
return ChatSerializer::saveToFile(m_chatModel, filePath);
}
SerializationResult ChatHistoryStore::load(const QString &filePath) const
{
return ChatSerializer::loadFromFile(m_chatModel, filePath);
}
void ChatHistoryStore::showSaveDialog()
{
QString initialDir = historyDir();
QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
dialog->setAcceptMode(QFileDialog::AcceptSave);
dialog->setFileMode(QFileDialog::AnyFile);
dialog->setNameFilter(tr("JSON files (*.json)"));
dialog->setDefaultSuffix("json");
if (!initialDir.isEmpty()) {
dialog->setDirectory(initialDir);
dialog->selectFile(suggestedFileName() + ".json");
}
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
if (result == QFileDialog::Accepted) {
QStringList files = dialog->selectedFiles();
if (!files.isEmpty()) {
emit saveRequested(files.first());
}
}
dialog->deleteLater();
});
dialog->open();
}
void ChatHistoryStore::showLoadDialog()
{
QString initialDir = historyDir();
QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
dialog->setAcceptMode(QFileDialog::AcceptOpen);
dialog->setFileMode(QFileDialog::ExistingFile);
dialog->setNameFilter(tr("JSON files (*.json)"));
if (!initialDir.isEmpty()) {
dialog->setDirectory(initialDir);
}
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
if (result == QFileDialog::Accepted) {
QStringList files = dialog->selectedFiles();
if (!files.isEmpty()) {
emit loadRequested(files.first());
}
}
dialog->deleteLater();
});
dialog->open();
}
void ChatHistoryStore::openHistoryFolder() const
{
QString path;
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
Settings::ProjectSettings projectSettings(project);
path = projectSettings.chatHistoryPath().toFSPathString();
} else {
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
path = baseDir.filePath("qodeassist/chat_history");
}
QDir dir(path);
if (!dir.exists()) {
dir.mkpath(".");
}
QUrl url = QUrl::fromLocalFile(dir.absolutePath());
QDesktopServices::openUrl(url);
}
QString ChatHistoryStore::generateChatFileName(const QString &shortMessage, const QString &dir) const
{
static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
static const QRegularExpression underSymbols = QRegularExpression("_+");
QStringList parts;
QString sanitizedMessage = shortMessage;
sanitizedMessage.replace(saitizeSymbols, "_");
sanitizedMessage.replace(underSymbols, "_");
sanitizedMessage = sanitizedMessage.trimmed();
if (!sanitizedMessage.isEmpty()) {
if (sanitizedMessage.startsWith('_')) {
sanitizedMessage.remove(0, 1);
}
if (sanitizedMessage.endsWith('_')) {
sanitizedMessage.chop(1);
}
QString fullPath = QDir(dir).filePath(sanitizedMessage);
QFileInfo fileInfo(fullPath);
if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
parts << sanitizedMessage;
}
}
parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
QString fileName = parts.join("_");
QString fullPath = QDir(dir).filePath(fileName);
QFileInfo finalCheck(fullPath);
if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
}
return fileName;
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,47 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QString>
#include "ChatSerializer.hpp"
namespace QodeAssist::Chat {
class ChatModel;
class ChatHistoryStore : public QObject
{
Q_OBJECT
public:
explicit ChatHistoryStore(ChatModel *chatModel, QObject *parent = nullptr);
QString historyDir() const;
QString suggestedFileName() const;
QString autosaveFilePath(const QString &recentFilePath) const;
QString autosaveFilePath(
const QString &recentFilePath,
const QString &firstMessage,
bool hasImageAttachments) const;
SerializationResult save(const QString &filePath) const;
SerializationResult load(const QString &filePath) const;
void showSaveDialog();
void showLoadDialog();
void openHistoryFolder() const;
signals:
void saveRequested(const QString &filePath);
void loadRequested(const QString &filePath);
private:
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
ChatModel *m_chatModel;
};
} // namespace QodeAssist::Chat

View File

@@ -11,7 +11,6 @@
#include <QUrl>
#include <QtQml>
#include "ChatAssistantSettings.hpp"
#include "Logger.hpp"
#include "context/ChangesManager.h"
@@ -20,14 +19,6 @@ namespace QodeAssist::Chat {
ChatModel::ChatModel(QObject *parent)
: QAbstractListModel(parent)
{
auto &settings = Settings::chatAssistantSettings();
connect(
&settings.chatTokensThreshold,
&Utils::BaseAspect::changed,
this,
&ChatModel::tokensThresholdChanged);
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditApplied,
this,
@@ -86,6 +77,16 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
case Roles::IsRedacted: {
return message.isRedacted;
}
case Roles::PromptTokens:
return message.promptTokens;
case Roles::CompletionTokens:
return message.completionTokens;
case Roles::CachedPromptTokens:
return message.cachedPromptTokens;
case Roles::ReasoningTokens:
return message.reasoningTokens;
case Roles::TotalTokens:
return message.promptTokens + message.completionTokens;
case Roles::Images: {
QVariantList imagesList;
for (const auto &image : message.images) {
@@ -124,6 +125,11 @@ QHash<int, QByteArray> ChatModel::roleNames() const
roles[Roles::Attachments] = "attachments";
roles[Roles::IsRedacted] = "isRedacted";
roles[Roles::Images] = "images";
roles[Roles::PromptTokens] = "promptTokens";
roles[Roles::CompletionTokens] = "completionTokens";
roles[Roles::CachedPromptTokens] = "cachedPromptTokens";
roles[Roles::ReasoningTokens] = "reasoningTokens";
roles[Roles::TotalTokens] = "totalTokens";
return roles;
}
@@ -207,6 +213,7 @@ void ChatModel::clear()
m_messages.clear();
endResetModel();
emit modelReseted();
emit sessionUsageChanged();
}
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
@@ -310,12 +317,6 @@ QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) con
return messages;
}
int ChatModel::tokensThreshold() const
{
auto &settings = Settings::chatAssistantSettings();
return settings.chatTokensThreshold();
}
QString ChatModel::lastMessageId() const
{
return !m_messages.isEmpty() ? m_messages.last().id : "";
@@ -330,11 +331,15 @@ void ChatModel::resetModelTo(int index)
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
m_messages.remove(index, m_messages.size() - index);
endRemoveRows();
emit sessionUsageChanged();
}
}
void ChatModel::addToolExecutionStatus(
const QString &requestId, const QString &toolId, const QString &toolName)
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments)
{
QString content = toolName;
@@ -345,11 +350,15 @@ void ChatModel::addToolExecutionStatus(
&& m_messages.last().role == ChatRole::Tool) {
Message &lastMessage = m_messages.last();
lastMessage.content = content;
lastMessage.toolName = toolName;
lastMessage.toolArguments = toolArguments;
LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1));
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
} else {
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message newMessage{ChatRole::Tool, content, toolId};
newMessage.toolName = toolName;
newMessage.toolArguments = toolArguments;
m_messages.append(newMessage);
endInsertRows();
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
@@ -358,6 +367,38 @@ void ChatModel::addToolExecutionStatus(
}
}
void ChatModel::dropTrailingAssistantMessage(const QString &requestId)
{
if (m_messages.isEmpty())
return;
const Message &last = m_messages.last();
if (last.role != ChatRole::Assistant || last.id != requestId)
return;
const int idx = m_messages.size() - 1;
beginRemoveRows(QModelIndex(), idx, idx);
m_messages.removeLast();
endRemoveRows();
LOG_MESSAGE(QString("Dropped leaked pre-tool assistant message at index %1").arg(idx));
}
void ChatModel::setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) {
m_messages[i].toolName = toolName;
m_messages[i].toolArguments = toolArguments;
m_messages[i].toolResult = toolResult;
return;
}
}
}
void ChatModel::updateToolResult(
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
{
@@ -377,6 +418,8 @@ void ChatModel::updateToolResult(
for (int i = m_messages.size() - 1; i >= 0; --i) {
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
m_messages[i].content = toolName + "\n" + result;
m_messages[i].toolName = toolName;
m_messages[i].toolResult = result;
emit dataChanged(index(i), index(i));
toolMessageFound = true;
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
@@ -507,6 +550,62 @@ void ChatModel::updateMessageContent(const QString &messageId, const QString &ne
}
}
void ChatModel::setMessageUsage(
const QString &messageId,
int promptTokens,
int completionTokens,
int cachedPromptTokens,
int reasoningTokens)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].id != messageId)
continue;
m_messages[i].promptTokens = promptTokens;
m_messages[i].completionTokens = completionTokens;
m_messages[i].cachedPromptTokens = cachedPromptTokens;
m_messages[i].reasoningTokens = reasoningTokens;
emit dataChanged(
index(i),
index(i),
{Roles::PromptTokens,
Roles::CompletionTokens,
Roles::CachedPromptTokens,
Roles::ReasoningTokens,
Roles::TotalTokens});
emit sessionUsageChanged();
return;
}
}
int ChatModel::sessionPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.promptTokens;
return total;
}
int ChatModel::sessionCompletionTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.completionTokens;
return total;
}
int ChatModel::sessionCachedPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.cachedPromptTokens;
return total;
}
int ChatModel::sessionTotalTokens() const
{
return sessionPromptTokens() + sessionCompletionTokens();
}
void ChatModel::setLoadingFromHistory(bool loading)
{
m_loadingFromHistory = loading;

View File

@@ -8,6 +8,7 @@
#include <QAbstractListModel>
#include <QJsonArray>
#include <QJsonObject>
#include <QtQmlIntegration>
#include "context/ContentFile.hpp"
@@ -17,14 +18,28 @@ namespace QodeAssist::Chat {
class ChatModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int tokensThreshold READ tokensThreshold NOTIFY tokensThresholdChanged FINAL)
Q_PROPERTY(int sessionPromptTokens READ sessionPromptTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int sessionCompletionTokens READ sessionCompletionTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int sessionCachedPromptTokens READ sessionCachedPromptTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int sessionTotalTokens READ sessionTotalTokens NOTIFY sessionUsageChanged FINAL)
QML_ELEMENT
public:
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
Q_ENUM(ChatRole)
enum Roles { RoleType = Qt::UserRole, Content, Attachments, IsRedacted, Images };
enum Roles {
RoleType = Qt::UserRole,
Content,
Attachments,
IsRedacted,
Images,
PromptTokens,
CompletionTokens,
CachedPromptTokens,
ReasoningTokens,
TotalTokens
};
Q_ENUM(Roles)
struct ImageAttachment
@@ -44,6 +59,15 @@ public:
QList<Context::ContentFile> attachments;
QList<ImageAttachment> images;
QString toolName;
QJsonObject toolArguments;
QString toolResult;
int promptTokens = 0;
int completionTokens = 0;
int cachedPromptTokens = 0;
int reasoningTokens = 0;
};
explicit ChatModel(QObject *parent = nullptr);
@@ -66,15 +90,22 @@ public:
QVector<Message> getChatHistory() const;
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
int tokensThreshold() const;
QString currentModel() const;
QString lastMessageId() const;
Q_INVOKABLE void resetModelTo(int index);
void addToolExecutionStatus(
const QString &requestId, const QString &toolId, const QString &toolName);
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments);
void dropTrailingAssistantMessage(const QString &requestId);
void setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult);
void updateToolResult(
const QString &requestId,
const QString &toolId,
@@ -84,6 +115,18 @@ public:
const QString &requestId, const QString &thinking, const QString &signature);
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
void updateMessageContent(const QString &messageId, const QString &newContent);
void setMessageUsage(
const QString &messageId,
int promptTokens,
int completionTokens,
int cachedPromptTokens,
int reasoningTokens);
int sessionPromptTokens() const;
int sessionCompletionTokens() const;
int sessionCachedPromptTokens() const;
int sessionTotalTokens() const;
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
@@ -92,8 +135,8 @@ public:
QString chatFilePath() const;
signals:
void tokensThresholdChanged();
void modelReseted();
void sessionUsageChanged();
private slots:
void onFileEditApplied(const QString &editId);

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
#pragma once
#include <QPointer>
#include <QQuickItem>
#include <QVariantList>
@@ -12,9 +13,19 @@
#include "pluginllmcore/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ChatCompressor;
class AgentRoleController;
class ChatConfigurationController;
class FileEditController;
class InputTokenCounter;
class ChatHistoryStore;
class SessionFileRegistry;
class ChatRootView : public QQuickItem
{
@@ -52,11 +63,14 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL)
Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL)
Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL)
QML_ELEMENT
public:
ChatRootView(QQuickItem *parent = nullptr);
~ChatRootView() override;
ChatModel *chatModel() const;
QString currentTemplate() const;
@@ -91,6 +105,11 @@ public:
Q_INVOKABLE void openFileInEditor(const QString &filePath);
Q_INVOKABLE void relocateToSplit();
Q_INVOKABLE void relocateToWindow();
void consumePendingChatFile();
Q_INVOKABLE void updateInputTokensCount();
int inputTokensCount() const;
@@ -122,6 +141,8 @@ public:
Q_INVOKABLE QString getRuleContent(int index);
Q_INVOKABLE void refreshRules();
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
bool useTools() const;
void setUseTools(bool enabled);
bool useThinking() const;
@@ -164,6 +185,13 @@ public:
bool isCompressing() const;
bool isInEditor() const;
void setInEditor(bool value);
QString chatTitle() const;
Q_INVOKABLE void requestNewChat();
public slots:
void sendMessage(const QString &message);
void copyToClipboard(const QString &text);
@@ -209,15 +237,34 @@ signals:
void compressionCompleted(const QString &compressedChatPath);
void compressionFailed(const QString &error);
void isInEditorChanged();
void chatTitleChanged();
void openFilesChanged();
void closeHostRequested();
private:
void updateFileEditStatus(const QString &editId, const QString &status);
QString getChatsHistoryDir() const;
QString getSuggestedFileName() const;
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
QString computeChatTitle() const;
void triggerOpenChatCommand(Utils::Id commandId);
void handOffSession();
bool deferSendForAutoCompress(
const QString &message,
const QStringList &attachments,
const QStringList &linkedFiles,
bool useTools,
bool useThinking);
void dispatchSend(
const QString &message,
const QStringList &attachments,
const QStringList &linkedFiles,
bool useTools,
bool useThinking);
bool hasImageAttachments(const QStringList &attachments) const;
SessionFileRegistry *sessionFileRegistry() const;
Skills::SkillsManager *skillsManager() const;
ChatModel *m_chatModel;
PluginLLMCore::PromptProviderChat m_promptProvider;
ClientInterface *m_clientInterface;
@@ -226,28 +273,36 @@ private:
QString m_recentFilePath;
QStringList m_attachmentFiles;
QStringList m_linkedFiles;
int m_messageTokensCount{0};
int m_inputTokensCount{0};
struct PendingSend {
QString message;
QStringList attachments;
QStringList linkedFiles;
bool useTools = false;
bool useThinking = false;
bool active = false;
};
PendingSend m_pendingSend;
bool m_isSyncOpenFiles;
bool m_isInEditor = false;
mutable QString m_cachedChatTitle;
QList<Core::IEditor *> m_currentEditors;
bool m_isRequestInProgress;
QString m_lastErrorMessage;
QVariantList m_activeRules;
QString m_currentMessageRequestId;
int m_currentMessageTotalEdits{0};
int m_currentMessageAppliedEdits{0};
int m_currentMessagePendingEdits{0};
int m_currentMessageRejectedEdits{0};
QString m_lastInfoMessage;
QStringList m_availableConfigurations;
QString m_currentConfiguration;
QStringList m_availableAgentRoles;
QString m_currentAgentRole;
ChatCompressor *m_chatCompressor;
AgentRoleController *m_agentRoleController;
ChatConfigurationController *m_configurationController;
FileEditController *m_fileEditController;
InputTokenCounter *m_tokenCounter;
ChatHistoryStore *m_historyStore;
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
mutable bool m_sessionFileRegistryResolved = false;
mutable QPointer<Skills::SkillsManager> m_skillsManager;
mutable bool m_skillsManagerResolved = false;
};
} // namespace QodeAssist::Chat

View File

@@ -80,6 +80,15 @@ QJsonObject ChatSerializer::serializeMessage(
messageObj["signature"] = message.signature;
}
if (message.role == ChatModel::ChatRole::Tool) {
if (!message.toolName.isEmpty())
messageObj["toolName"] = message.toolName;
if (!message.toolArguments.isEmpty())
messageObj["toolArguments"] = message.toolArguments;
if (!message.toolResult.isEmpty())
messageObj["toolResult"] = message.toolResult;
}
if (!message.attachments.isEmpty()) {
QJsonArray attachmentsArray;
for (const auto &attachment : message.attachments) {
@@ -103,6 +112,17 @@ QJsonObject ChatSerializer::serializeMessage(
messageObj["images"] = imagesArray;
}
if (message.promptTokens > 0 || message.completionTokens > 0) {
QJsonObject usageObj;
usageObj["promptTokens"] = message.promptTokens;
usageObj["completionTokens"] = message.completionTokens;
if (message.cachedPromptTokens > 0)
usageObj["cachedPromptTokens"] = message.cachedPromptTokens;
if (message.reasoningTokens > 0)
usageObj["reasoningTokens"] = message.reasoningTokens;
messageObj["usage"] = usageObj;
}
return messageObj;
}
@@ -115,6 +135,9 @@ ChatModel::Message ChatSerializer::deserializeMessage(
message.id = json["id"].toString();
message.isRedacted = json["isRedacted"].toBool(false);
message.signature = json["signature"].toString();
message.toolName = json["toolName"].toString();
message.toolArguments = json["toolArguments"].toObject();
message.toolResult = json["toolResult"].toString();
if (json.contains("attachments")) {
QJsonArray attachmentsArray = json["attachments"].toArray();
@@ -139,6 +162,14 @@ ChatModel::Message ChatSerializer::deserializeMessage(
}
}
if (json.contains("usage")) {
const QJsonObject usageObj = json["usage"].toObject();
message.promptTokens = usageObj["promptTokens"].toInt();
message.completionTokens = usageObj["completionTokens"].toInt();
message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt();
message.reasoningTokens = usageObj["reasoningTokens"].toInt();
}
return message;
}
@@ -180,6 +211,10 @@ bool ChatSerializer::deserializeChat(
message.images,
message.isRedacted,
message.signature);
if (message.role == ChatModel::ChatRole::Tool) {
model->setToolMessageData(
message.id, message.toolName, message.toolArguments, message.toolResult);
}
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
.arg(message.images.size())
.arg(message.isRedacted)

View File

@@ -3,14 +3,22 @@
#include "ChatView.hpp"
#include <QQmlComponent>
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickItem>
#include <QSettings>
#include <QVariantMap>
#include <coreplugin/actionmanager/actionmanager.h>
#include <coreplugin/actionmanager/command.h>
#include <logger/Logger.hpp>
#include "ChatRootView.hpp"
#include "QodeAssistConstants.hpp"
#include "SessionFileRegistry.hpp"
#include "sources/skills/SkillsManager.hpp"
namespace {
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
| Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint
@@ -19,30 +27,65 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win
namespace QodeAssist::Chat {
ChatView::ChatView()
: m_isPin(false)
ChatView::ChatView(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager)
: QQuickView{engine, nullptr}
, m_isPin(false)
{
setTitle("QodeAssist Chat");
engine()->rootContext()->setContextProperty("_chatview", this);
setSource(QUrl("qrc:/qt/qml/ChatView/qml/RootItem.qml"));
/// @note setup quick view content
{
auto context = new QQmlContext{engine, this};
context->setContextProperty("_chatview", this);
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
context->setContextProperty("skillsManager", skillsManager);
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
auto rootItem = component->create(context);
setContent(component->url(), component, rootItem);
}
if (auto rootView = qobject_cast<ChatRootView *>(rootObject())) {
connect(
rootView,
&ChatRootView::closeHostRequested,
this,
&QWindow::close,
Qt::QueuedConnection);
}
setResizeMode(QQuickView::SizeRootObjectToView);
setMinimumSize({400, 300});
setFlags(baseFlags);
if (auto action = Core::ActionManager::command("QodeAssist.CloseChatView")) {
m_closeShortcut = new QShortcut(action->keySequence(), this);
connect(m_closeShortcut, &QShortcut::activated, this, &QQuickView::close);
connect(action, &Core::Command::keySequenceChanged, this, [action, this]() {
if (m_closeShortcut) {
m_closeShortcut->setKey(action->keySequence());
}
});
}
bindCommandShortcut("QodeAssist.CloseChatView", [this] { close(); });
bindCommandShortcut(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE, [this] {
QMetaObject::invokeMethod(rootObject(), "sendChatMessage");
});
bindCommandShortcut(Constants::QODE_ASSIST_CHAT_CLEAR_SESSION, [this] {
QMetaObject::invokeMethod(rootObject(), "clearChat");
});
restoreSettings();
}
void ChatView::bindCommandShortcut(Utils::Id commandId,
const std::function<void()> &onActivated)
{
auto command = Core::ActionManager::command(commandId);
if (!command)
return;
auto shortcut = new QShortcut(command->keySequence(), this);
connect(shortcut, &QShortcut::activated, this, onActivated);
connect(command, &Core::Command::keySequenceChanged, shortcut, [command, shortcut]() {
shortcut->setKey(command->keySequence());
});
}
void ChatView::closeEvent(QCloseEvent *event)
{
saveSettings();

View File

@@ -3,17 +3,30 @@
#pragma once
#include <functional>
#include <utils/id.h>
#include <QQuickView>
#include <QShortcut>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class ChatView : public QQuickView
{
Q_OBJECT
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
public:
ChatView();
ChatView(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager);
bool isPin() const;
void setIsPin(bool newIsPin);
@@ -27,9 +40,9 @@ protected:
private:
void saveSettings();
void restoreSettings();
void bindCommandShortcut(Utils::Id commandId, const std::function<void()> &onActivated);
bool m_isPin;
QShortcut *m_closeShortcut;
};
} // namespace QodeAssist::Chat

View File

@@ -3,16 +3,54 @@
#include "ChatWidget.hpp"
#include <QApplication>
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickItem>
#include <coreplugin/icontext.h>
#include <coreplugin/icore.h>
#include "QodeAssistConstants.hpp"
#include "SessionFileRegistry.hpp"
#include "sources/skills/SkillsManager.hpp"
namespace QodeAssist::Chat {
ChatWidget::ChatWidget(QWidget *parent)
: QQuickWidget(parent)
ChatWidget::ChatWidget(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager,
bool registerOwnContext,
QWidget *parent)
: QQuickWidget{engine, parent}
{
setSource(QUrl("qrc:/qt/qml/ChatView/qml/RootItem.qml"));
/// @note setup quick view content
{
auto context = new QQmlContext{engine, this};
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
context->setContextProperty("skillsManager", skillsManager);
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
auto rootItem = component->create(context);
setContent(component->url(), component, rootItem);
}
setResizeMode(QQuickWidget::SizeRootObjectToView);
setFocusPolicy(Qt::StrongFocus);
if (registerOwnContext) {
auto ideContext = new Core::IContext{this};
ideContext->setWidget(this);
ideContext->setContext(Core::Context{Constants::QODE_ASSIST_CHAT_CONTEXT});
Core::ICore::addContextObject(ideContext);
}
}
void ChatWidget::focusInEvent(QFocusEvent *event)
{
QQuickWidget::focusInEvent(event);
if (rootObject())
QMetaObject::invokeMethod(rootObject(), "focusInput");
}
void ChatWidget::clear()
@@ -24,4 +62,35 @@ void ChatWidget::scrollToBottom()
{
QMetaObject::invokeMethod(rootObject(), "scrollToBottom");
}
void ChatWidget::focusInput()
{
setFocus(Qt::OtherFocusReason);
QMetaObject::invokeMethod(rootObject(), "focusInput");
}
bool ChatWidget::isChatFocused() const
{
return hasFocus() || (rootObject() && rootObject()->hasActiveFocus());
}
void ChatWidget::sendMessage()
{
QMetaObject::invokeMethod(rootObject(), "sendChatMessage");
}
void ChatWidget::clearSession()
{
QMetaObject::invokeMethod(rootObject(), "clearChat");
}
ChatWidget *ChatWidget::focusedInstance()
{
for (QWidget *widget = QApplication::focusWidget(); widget;
widget = widget->parentWidget()) {
if (auto chatWidget = qobject_cast<ChatWidget *>(widget))
return chatWidget;
}
return nullptr;
}
} // namespace QodeAssist::Chat

View File

@@ -5,21 +5,43 @@
#include <QtQuickWidgets/QtQuickWidgets>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class ChatWidget : public QQuickWidget
{
Q_OBJECT
public:
explicit ChatWidget(QWidget *parent = nullptr);
explicit ChatWidget(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager,
bool registerOwnContext = true,
QWidget *parent = nullptr);
~ChatWidget() = default;
Q_INVOKABLE void clear();
Q_INVOKABLE void scrollToBottom();
Q_INVOKABLE void focusInput();
void sendMessage();
void clearSession();
bool isChatFocused() const;
static ChatWidget *focusedInstance();
signals:
void clearPressed();
protected:
void focusInEvent(QFocusEvent *event) override;
};
} // namespace QodeAssist::Chat

View File

@@ -14,6 +14,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QUuid>
#include <coreplugin/editormanager/editormanager.h>
@@ -28,27 +29,36 @@
#include <LLMQore/ToolsManager.hpp>
#include "tools/ReadOriginalHistoryTool.hpp"
#include "tools/TodoTool.hpp"
#include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp"
#include "GeneralSettings.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "ProvidersManager.hpp"
#include "SkillsSettings.hpp"
#include "ToolsSettings.hpp"
#include <RulesLoader.hpp>
#include <context/ChangesManager.h>
#include <sources/skills/SkillsManager.hpp>
namespace QodeAssist::Chat {
ClientInterface::ClientInterface(
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
, m_promptProvider(promptProvider)
, m_chatModel(chatModel)
, m_contextManager(new Context::ContextManager(this))
{}
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
{
m_skillsManager = skillsManager;
}
ClientInterface::~ClientInterface()
{
cancelRequest();
@@ -61,6 +71,11 @@ void ClientInterface::sendMessage(
bool useTools,
bool useThinking)
{
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
LOG_MESSAGE("Ignoring empty chat message");
return;
}
cancelRequest();
m_accumulatedResponses.clear();
@@ -180,15 +195,85 @@ void ClientInterface::sendMessage(
systemPrompt += QString("\n# No active project in IDE");
}
if (m_skillsManager && Settings::skillsSettings().enableSkills()) {
QStringList projectSkillDirs;
if (project) {
Settings::ProjectSettings projectSettings(project);
projectSkillDirs = Settings::SkillsSettings::splitLines(
projectSettings.projectSkillDirs());
}
m_skillsManager->configure(
project ? project->projectDirectory().toFSPathString() : QString(),
Settings::SkillsSettings::splitPaths(
Settings::skillsSettings().globalSkillRoots()),
projectSkillDirs);
const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies();
if (!alwaysOnSkills.isEmpty())
systemPrompt += QString("\n\n") + alwaysOnSkills;
const QString skillsCatalog = m_skillsManager->catalogText();
if (!skillsCatalog.isEmpty())
systemPrompt += QString("\n\n") + skillsCatalog;
static const QRegularExpression skillCommand(
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
QStringList invokedSkillNames;
auto skillMatch = skillCommand.globalMatch(message);
while (skillMatch.hasNext()) {
const QString skillName = skillMatch.next().captured(1);
if (invokedSkillNames.contains(skillName))
continue;
const auto invokedSkill = m_skillsManager->findByName(skillName);
if (invokedSkill && !invokedSkill->body.isEmpty()) {
invokedSkillNames << skillName;
systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2")
.arg(invokedSkill->name, invokedSkill->body);
}
}
}
if (!linkedFiles.isEmpty()) {
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
}
context.systemPrompt = systemPrompt;
}
const bool toolHistory = promptTemplate->supportsToolHistory();
QVector<PluginLLMCore::Message> messages;
int toolCallMsgIdx = -1;
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) {
if (msg.role == ChatModel::ChatRole::Tool) {
if (!toolHistory || msg.toolName.isEmpty()) {
continue;
}
if (toolCallMsgIdx < 0) {
PluginLLMCore::Message assistantCall;
assistantCall.role = "assistant";
messages.append(assistantCall);
toolCallMsgIdx = messages.size() - 1;
}
PluginLLMCore::ToolCall call;
call.id = msg.id;
call.name = msg.toolName;
call.arguments = msg.toolArguments;
messages[toolCallMsgIdx].toolCalls.append(call);
PluginLLMCore::Message toolResult;
toolResult.role = "tool";
toolResult.toolCallId = msg.id;
toolResult.toolName = msg.toolName;
toolResult.content = msg.toolResult;
messages.append(toolResult);
continue;
}
toolCallMsgIdx = -1;
if (msg.role == ChatModel::ChatRole::FileEdit) {
continue;
}
@@ -257,6 +342,12 @@ void ClientInterface::sendMessage(
this,
&ClientInterface::handleFullResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFinalized,
this,
&ClientInterface::handleRequestFinalized,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFailed,
@@ -289,7 +380,7 @@ void ClientInterface::sendMessage(
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
QJsonObject request{{"id", requestId}};
m_activeRequests[requestId] = {request, provider};
m_activeRequests[requestId] = {request, provider, !toolHistory};
emit requestStarted(requestId);
@@ -299,6 +390,10 @@ void ClientInterface::sendMessage(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->setCurrentSessionId(m_chatFilePath);
}
if (auto *historyTool = qobject_cast<QodeAssist::Tools::ReadOriginalHistoryTool *>(
provider->toolsManager()->tool("read_original_history"))) {
historyTool->setCurrentSessionId(m_chatFilePath);
}
}
}
@@ -449,6 +544,29 @@ void ClientInterface::handleFullResponse(const QString &requestId, const QString
m_awaitingContinuation.remove(requestId);
}
void ClientInterface::handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (!m_activeRequests.contains(requestId))
return;
if (!info.usage)
return;
const auto &u = *info.usage;
m_chatModel->setMessageUsage(
requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
emit messageUsageReceived(
u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(u.promptTokens)
.arg(u.completionTokens)
.arg(u.cachedPromptTokens)
.arg(u.reasoningTokens));
}
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
{
auto it = m_activeRequests.find(requestId);
@@ -485,14 +603,21 @@ void ClientInterface::handleThinkingBlockReceived(
}
void ClientInterface::handleToolExecutionStarted(
const QString &requestId, const QString &toolId, const QString &toolName)
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments)
{
if (!m_activeRequests.contains(requestId)) {
const auto requestIt = m_activeRequests.constFind(requestId);
if (requestIt == m_activeRequests.constEnd()) {
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
return;
}
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName);
if (requestIt->dropPreToolText) {
m_chatModel->dropTrailingAssistantMessage(requestId);
}
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments);
m_awaitingContinuation.insert(requestId);
}

View File

@@ -11,8 +11,13 @@
#include "ChatModel.hpp"
#include "Provider.hpp"
#include "pluginllmcore/IPromptProvider.hpp"
#include <LLMQore/BaseClient.hpp>
#include <context/ContextManager.hpp>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ClientInterface : public QObject
@@ -24,6 +29,8 @@ public:
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
~ClientInterface();
void setSkillsManager(Skills::SkillsManager *skillsManager);
void sendMessage(
const QString &message,
const QList<QString> &attachments = {},
@@ -42,15 +49,21 @@ signals:
void errorOccurred(const QString &error);
void messageReceivedCompletely();
void requestStarted(const QString &requestId);
void messageUsageReceived(
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
private slots:
void handlePartialResponse(const QString &requestId, const QString &partialText);
void handleFullResponse(const QString &requestId, const QString &fullText);
void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleRequestFailed(const QString &requestId, const QString &error);
void handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature);
void handleToolExecutionStarted(
const QString &requestId, const QString &toolId, const QString &toolName);
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments);
void handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
@@ -71,11 +84,13 @@ private:
{
QJsonObject originalRequest;
PluginLLMCore::Provider *provider;
bool dropPreToolText = false;
};
PluginLLMCore::IPromptProvider *m_promptProvider = nullptr;
ChatModel *m_chatModel;
Context::ContextManager *m_contextManager;
Skills::SkillsManager *m_skillsManager = nullptr;
QString m_chatFilePath;
QHash<QString, RequestContext> m_activeRequests;

View File

@@ -0,0 +1,334 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "FileEditController.hpp"
#include <QJsonDocument>
#include <QJsonObject>
#include <coreplugin/editormanager/editormanager.h>
#include <texteditor/texteditor.h>
#include "ChatModel.hpp"
#include "Logger.hpp"
#include "context/ChangesManager.h"
namespace QodeAssist::Chat {
FileEditController::FileEditController(ChatModel *chatModel, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
{
auto &changes = Context::ChangesManager::instance();
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditUndone, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &) {
updateStats();
});
}
void FileEditController::setCurrentRequestId(const QString &requestId)
{
if (!m_currentRequestId.isEmpty()) {
LOG_MESSAGE(QString("Clearing previous message requestId: %1").arg(m_currentRequestId));
}
m_currentRequestId = requestId;
LOG_MESSAGE(QString("New message request started: %1").arg(requestId));
updateStats();
}
void FileEditController::clearCurrentRequestId()
{
m_currentRequestId.clear();
updateStats();
}
int FileEditController::totalEdits() const
{
return m_totalEdits;
}
int FileEditController::appliedEdits() const
{
return m_appliedEdits;
}
int FileEditController::pendingEdits() const
{
return m_pendingEdits;
}
int FileEditController::rejectedEdits() const
{
return m_rejectedEdits;
}
void FileEditController::applyFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
emit infoMessage(QString("File edit applied successfully"));
updateFileEditStatus(editId, "applied");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to apply file edit")
: QString("Failed to apply file edit: %1").arg(edit.statusMessage));
}
}
void FileEditController::rejectFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
emit infoMessage(QString("File edit rejected"));
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to reject file edit")
: QString("Failed to reject file edit: %1").arg(edit.statusMessage));
}
}
void FileEditController::undoFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
emit infoMessage(QString("File edit undone successfully"));
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to undo file edit")
: QString("Failed to undo file edit: %1").arg(edit.statusMessage));
}
}
void FileEditController::openFileEditInEditor(const QString &editId)
{
LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId));
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (edit.editId.isEmpty()) {
emit errorOccurred(QString("File edit not found: %1").arg(editId));
return;
}
Utils::FilePath filePath = Utils::FilePath::fromString(edit.filePath);
Core::IEditor *editor = Core::EditorManager::openEditor(filePath);
if (!editor) {
emit errorOccurred(QString("Failed to open file in editor: %1").arg(edit.filePath));
return;
}
auto *textEditor = qobject_cast<TextEditor::BaseTextEditor *>(editor);
if (textEditor && textEditor->editorWidget()) {
QTextDocument *doc = textEditor->editorWidget()->document();
if (doc) {
QString currentContent = doc->toPlainText();
int position = -1;
if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) {
position = currentContent.indexOf(edit.newContent);
} else if (!edit.oldContent.isEmpty()) {
position = currentContent.indexOf(edit.oldContent);
}
if (position >= 0) {
QTextCursor cursor(doc);
cursor.setPosition(position);
textEditor->editorWidget()->setTextCursor(cursor);
textEditor->editorWidget()->centerCursor();
}
}
}
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath));
}
void FileEditController::updateFileEditStatus(const QString &editId, const QString &status)
{
auto messages = m_chatModel->getChatHistory();
for (int i = 0; i < messages.size(); ++i) {
if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) {
QString content = messages[i].content;
const QString marker = "QODEASSIST_FILE_EDIT:";
int markerPos = content.indexOf(marker);
QString jsonStr = content;
if (markerPos >= 0) {
jsonStr = content.mid(markerPos + marker.length());
}
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject obj = doc.object();
obj["status"] = status;
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (!edit.statusMessage.isEmpty()) {
obj["status_message"] = edit.statusMessage;
}
QString updatedContent = marker
+ QString::fromUtf8(
QJsonDocument(obj).toJson(QJsonDocument::Compact));
m_chatModel->updateMessageContent(editId, updatedContent);
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
}
break;
}
}
updateStats();
}
void FileEditController::applyAllForCurrentMessage()
{
if (m_currentRequestId.isEmpty()) {
emit errorOccurred(QString("No active message with file edits"));
return;
}
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
.reapplyAllEditsForRequest(m_currentRequestId, &errorMsg);
if (success) {
emit infoMessage(QString("All file edits applied successfully"));
} else {
emit errorOccurred(
errorMsg.isEmpty()
? QString("Failed to apply some file edits")
: QString("Failed to apply some file edits:\n%1").arg(errorMsg));
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
updateStats();
}
void FileEditController::undoAllForCurrentMessage()
{
if (m_currentRequestId.isEmpty()) {
emit errorOccurred(QString("No active message with file edits"));
return;
}
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
.undoAllEditsForRequest(m_currentRequestId, &errorMsg);
if (success) {
emit infoMessage(QString("All file edits undone successfully"));
} else {
emit errorOccurred(
errorMsg.isEmpty()
? QString("Failed to undo some file edits")
: QString("Failed to undo some file edits:\n%1").arg(errorMsg));
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
updateStats();
}
void FileEditController::updateStats()
{
if (m_currentRequestId.isEmpty()) {
if (m_totalEdits != 0 || m_appliedEdits != 0 || m_pendingEdits != 0
|| m_rejectedEdits != 0) {
m_totalEdits = 0;
m_appliedEdits = 0;
m_pendingEdits = 0;
m_rejectedEdits = 0;
emit statsChanged();
}
return;
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
int total = edits.size();
int applied = 0;
int pending = 0;
int rejected = 0;
for (const auto &edit : edits) {
switch (edit.status) {
case Context::ChangesManager::Applied:
applied++;
break;
case Context::ChangesManager::Pending:
pending++;
break;
case Context::ChangesManager::Rejected:
rejected++;
break;
case Context::ChangesManager::Archived:
total--;
break;
}
}
bool changed = false;
if (m_totalEdits != total) {
m_totalEdits = total;
changed = true;
}
if (m_appliedEdits != applied) {
m_appliedEdits = applied;
changed = true;
}
if (m_pendingEdits != pending) {
m_pendingEdits = pending;
changed = true;
}
if (m_rejectedEdits != rejected) {
m_rejectedEdits = rejected;
changed = true;
}
if (changed) {
LOG_MESSAGE(
QString("Updated message edits stats: total=%1, applied=%2, pending=%3, rejected=%4")
.arg(total)
.arg(applied)
.arg(pending)
.arg(rejected));
emit statsChanged();
}
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,53 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QString>
namespace QodeAssist::Chat {
class ChatModel;
class FileEditController : public QObject
{
Q_OBJECT
public:
explicit FileEditController(ChatModel *chatModel, QObject *parent = nullptr);
void setCurrentRequestId(const QString &requestId);
void clearCurrentRequestId();
int totalEdits() const;
int appliedEdits() const;
int pendingEdits() const;
int rejectedEdits() const;
void applyFileEdit(const QString &editId);
void rejectFileEdit(const QString &editId);
void undoFileEdit(const QString &editId);
void openFileEditInEditor(const QString &editId);
void applyAllForCurrentMessage();
void undoAllForCurrentMessage();
void updateStats();
signals:
void statsChanged();
void infoMessage(const QString &message);
void errorOccurred(const QString &error);
private:
void updateFileEditStatus(const QString &editId, const QString &status);
ChatModel *m_chatModel;
QString m_currentRequestId;
int m_totalEdits{0};
int m_appliedEdits{0};
int m_pendingEdits{0};
int m_rejectedEdits{0};
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,183 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "InputTokenCounter.hpp"
#include <algorithm>
#include <LLMQore/ToolsManager.hpp>
#include <QJsonArray>
#include <QJsonDocument>
#include <utils/aspects.h>
#include "ChatAssistantSettings.hpp"
#include "ChatModel.hpp"
#include "GeneralSettings.hpp"
#include "Logger.hpp"
#include "ProvidersManager.hpp"
#include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp"
namespace QodeAssist::Chat {
InputTokenCounter::InputTokenCounter(
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
, m_contextManager(contextManager)
{
auto &settings = Settings::chatAssistantSettings();
connect(
&settings.useSystemPrompt,
&Utils::BaseAspect::changed,
this,
&InputTokenCounter::recompute);
connect(
&settings.systemPrompt, &Utils::BaseAspect::changed, this, &InputTokenCounter::recompute);
connect(
&settings.enableChatTools,
&Utils::BaseAspect::changed,
this,
&InputTokenCounter::recompute);
connect(&Settings::generalSettings().caProvider, &Utils::BaseAspect::changed, this, [this]() {
rewireToolsChangedConnection();
recompute();
});
rewireToolsChangedConnection();
recompute();
}
int InputTokenCounter::inputTokens() const
{
return m_inputTokens;
}
void InputTokenCounter::setMessage(const QString &message)
{
m_messageTokens = Context::TokenUtils::estimateTokens(message);
recompute();
}
void InputTokenCounter::setAttachments(const QStringList &attachments)
{
m_attachments = attachments;
recompute();
}
void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles)
{
m_linkedFiles = linkedFiles;
recompute();
}
void InputTokenCounter::rewireToolsChangedConnection()
{
if (m_toolsChangedConn)
QObject::disconnect(m_toolsChangedConn);
m_toolsChangedConn = {};
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName);
if (!provider)
return;
auto *tm = provider->toolsManager();
if (!tm)
return;
m_toolsChangedConn = connect(
tm, &::LLMQore::ToolRegistry::toolsChanged, this, &InputTokenCounter::recompute);
}
void InputTokenCounter::recompute()
{
int inputTokens = m_messageTokens;
auto &settings = Settings::chatAssistantSettings();
if (settings.useSystemPrompt()) {
inputTokens += Context::TokenUtils::estimateTokens(settings.systemPrompt());
}
const auto splitImageEstimate = [](const QStringList &paths, QStringList &textPaths) {
int imageTokens = 0;
for (const QString &p : paths) {
if (Context::TokenUtils::isImageFilePath(p))
imageTokens += Context::TokenUtils::estimateImageAttachmentTokens(p);
else
textPaths.append(p);
}
return imageTokens;
};
if (!m_attachments.isEmpty()) {
QStringList textPaths;
inputTokens += splitImageEstimate(m_attachments, textPaths);
if (!textPaths.isEmpty()) {
auto attachFiles = m_contextManager->getContentFiles(textPaths);
inputTokens += Context::TokenUtils::estimateFilesTokens(attachFiles);
}
}
if (!m_linkedFiles.isEmpty()) {
QStringList textPaths;
inputTokens += splitImageEstimate(m_linkedFiles, textPaths);
if (!textPaths.isEmpty()) {
auto linkFiles = m_contextManager->getContentFiles(textPaths);
inputTokens += Context::TokenUtils::estimateFilesTokens(linkFiles);
}
}
const auto &history = m_chatModel->getChatHistory();
for (const auto &message : history) {
inputTokens += Context::TokenUtils::estimateTokens(message.content);
inputTokens += 4; // + role
}
if (settings.enableChatTools()) {
const auto providerName = Settings::generalSettings().caProvider();
if (auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(
providerName)) {
if (auto *tm = provider->toolsManager()) {
const QJsonArray toolDefs = tm->getToolsDefinitions();
if (!toolDefs.isEmpty()) {
const QByteArray serialized
= QJsonDocument(toolDefs).toJson(QJsonDocument::Compact);
inputTokens += static_cast<int>(serialized.size() / 4);
}
}
}
}
m_inputTokens = static_cast<int>(inputTokens * m_calibrationFactor);
emit inputTokensChanged();
}
void InputTokenCounter::recordSent()
{
m_lastSentEstimate = m_calibrationFactor > 0.0
? static_cast<int>(m_inputTokens / m_calibrationFactor)
: m_inputTokens;
}
void InputTokenCounter::recordServerUsage(int promptTokens)
{
if (promptTokens <= 0 || m_lastSentEstimate <= 0)
return;
const double rawFactor
= static_cast<double>(promptTokens) / static_cast<double>(m_lastSentEstimate);
const double clamped = std::clamp(rawFactor, 0.5, 3.0);
m_calibrationFactor = 0.5 * m_calibrationFactor + 0.5 * clamped;
LOG_MESSAGE(QString("Token calibration: server=%1 estimated=%2 ratio=%3 ema=%4")
.arg(promptTokens)
.arg(m_lastSentEstimate)
.arg(rawFactor, 0, 'f', 3)
.arg(m_calibrationFactor, 0, 'f', 3));
recompute();
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,53 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QStringList>
namespace QodeAssist::Context {
class ContextManager;
}
namespace QodeAssist::Chat {
class ChatModel;
class InputTokenCounter : public QObject
{
Q_OBJECT
public:
InputTokenCounter(
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent = nullptr);
int inputTokens() const;
void setMessage(const QString &message);
void setAttachments(const QStringList &attachments);
void setLinkedFiles(const QStringList &linkedFiles);
void recompute();
void recordSent();
void recordServerUsage(int promptTokens);
signals:
void inputTokensChanged();
private:
void rewireToolsChangedConnection();
ChatModel *m_chatModel;
Context::ContextManager *m_contextManager;
QMetaObject::Connection m_toolsChangedConn;
QStringList m_attachments;
QStringList m_linkedFiles;
int m_messageTokens{0};
int m_inputTokens{0};
int m_lastSentEstimate{0};
double m_calibrationFactor{1.0};
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,67 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SessionFileRegistry.hpp"
#include <utility>
#include <QFileInfo>
namespace QodeAssist::Chat {
SessionFileRegistry::SessionFileRegistry(QObject *parent)
: QObject(parent)
{}
bool SessionFileRegistry::isLocked(const QString &path) const
{
return !path.isEmpty() && m_lockedPaths.contains(path);
}
bool SessionFileRegistry::lock(const QString &path)
{
if (path.isEmpty() || m_lockedPaths.contains(path)) {
return false;
}
m_lockedPaths.insert(path);
return true;
}
void SessionFileRegistry::release(const QString &path)
{
m_lockedPaths.remove(path);
}
void SessionFileRegistry::setPendingChatFile(const QString &path)
{
m_pendingChatFile = path;
}
QString SessionFileRegistry::takePendingChatFile()
{
return std::exchange(m_pendingChatFile, QString{});
}
QString SessionFileRegistry::uniqueFreePath(const QString &desiredPath) const
{
if (desiredPath.isEmpty() || !m_lockedPaths.contains(desiredPath)) {
return desiredPath;
}
const QFileInfo info(desiredPath);
const QString dir = info.path();
const QString base = info.completeBaseName();
const QString suffix = info.suffix();
for (int counter = 2;; ++counter) {
QString candidate = dir + '/' + base + '_' + QString::number(counter);
if (!suffix.isEmpty()) {
candidate += '.' + suffix;
}
if (!m_lockedPaths.contains(candidate)) {
return candidate;
}
}
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,38 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QSet>
#include <QString>
namespace QodeAssist::Chat {
// Shared registry of chat session (autosave) file paths that are currently held by a live
// chat instance. Lets every chat view — bottom pane, navigation panel, editor split — claim
// a unique history file so two sessions never autosave into the same path.
class SessionFileRegistry : public QObject
{
Q_OBJECT
public:
explicit SessionFileRegistry(QObject *parent = nullptr);
bool isLocked(const QString &path) const;
bool lock(const QString &path);
void release(const QString &path);
QString uniqueFreePath(const QString &desiredPath) const;
// Handoff slot for relocating a live chat between hosts (split <-> window): the source
// chat stores its history file here, the freshly created host picks it up exactly once.
void setPendingChatFile(const QString &path);
QString takePendingChatFile();
private:
QSet<QString> m_lockedPaths;
QString m_pendingChatFile;
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6 0H2.4C1.08 0 0 1.08 0 2.4V16.8C0 18.12 1.08 19.2 2.4 19.2H7.2V22.8C7.2 23.46 7.74 24 8.4 24H9C9.3 24 9.6 23.88 9.84 23.652L14.28 19.2H21.6C22.92 19.2 24 18.12 24 16.8V2.4C24 1.08 22.92 0 21.6 0ZM21.6 16.8H13.44L8.76 21.48L8.4 21.6V16.8H2.4V2.4H21.6V16.8Z" fill="black"/>
<rect x="11" y="5" width="2" height="9" rx="0.5" fill="black"/>
<rect x="7.5" y="8.5" width="9" height="2" rx="0.5" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -1,17 +1,6 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_74_52)">
<mask id="mask0_74_52" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
<path d="M44 0H0V44H44V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_74_52)">
<path d="M18 31C25.1797 31 31 25.1797 31 18C31 10.8203 25.1797 5 18 5C10.8203 5 5 10.8203 5 18C5 25.1797 10.8203 31 18 31Z" stroke="black" stroke-width="3.5"/>
<path d="M27 27L38 38" stroke="black" stroke-width="3.5" stroke-linecap="round"/>
<path d="M16.375 23L18.2841 11.3636H20.1023L18.1932 23H16.375ZM11.1648 20.1136L11.4659 18.2955H20.5568L20.2557 20.1136H11.1648ZM12.2841 23L14.1932 11.3636H16.0114L14.1023 23H12.2841ZM11.8295 16.0682L12.1364 14.25H21.2273L20.9205 16.0682H11.8295Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_52">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
<rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="black"/>
<rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="none"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 348 B

View File

@@ -0,0 +1,6 @@
<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
<rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="none"/>
<rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@@ -87,11 +87,28 @@ ChatRootView {
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + 10
isInEditor: root.isInEditor
saveButton.onClicked: root.showSaveDialog()
loadButton.onClicked: root.showLoadDialog()
clearButton.onClicked: root.clearChat()
newChatButton.onClicked: root.requestNewChat()
tokensBadge {
text: qsTr("%1/%2").arg(root.inputTokensCount).arg(root.chatModel.tokensThreshold)
readonly property int sessionPrompt: root.chatModel.sessionPromptTokens || 0
readonly property int sessionCompletion: root.chatModel.sessionCompletionTokens || 0
readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens || 0
text: sessionCached > 0
? qsTr("next ~%1 · session ↑%2 ↓%3 ↻%4")
.arg(root.inputTokensCount)
.arg(sessionPrompt)
.arg(sessionCompletion)
.arg(sessionCached)
: qsTr("next ~%1 · session ↑%2 ↓%3")
.arg(root.inputTokensCount)
.arg(sessionPrompt)
.arg(sessionCompletion)
ToolTip.text: sessionCached > 0
? qsTr("next request (estimate) · session prompt ↑ / completion ↓ / cached ↻ (provider cache hits)")
: qsTr("next request (estimate) · session prompt ↑ / completion ↓")
}
recentPath {
text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
@@ -103,6 +120,20 @@ ChatRootView {
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
}
relocateButton {
icon.source: (typeof _chatview !== 'undefined')
? "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
: "qrc:/qt/qml/ChatView/icons/open-in-window.svg"
ToolTip.text: (typeof _chatview !== 'undefined')
? qsTr("Move this chat to an editor tab")
: qsTr("Move this chat to a separate window")
onClicked: {
if (typeof _chatview !== 'undefined')
root.relocateToSplit()
else
root.relocateToWindow()
}
}
toolsButton {
checked: root.useTools
onCheckedChanged: {
@@ -259,6 +290,7 @@ ChatRootView {
id: chatItemInstance
width: parent.width
chatViewport: chatListView
msgModel: root.chatModel.processMessageContent(model.content)
messageAttachments: model.attachments
messageImages: model.images
@@ -270,6 +302,10 @@ ChatRootView {
codeFontSize: root.codeFontSize
textFontSize: root.textFontSize
textFormat: root.textFormat
promptTokens: model.promptTokens || 0
completionTokens: model.completionTokens || 0
cachedPromptTokens: model.cachedPromptTokens || 0
reasoningTokens: model.reasoningTokens || 0
onResetChatToMessage: function(idx) {
messageInput.text = model.content
@@ -372,15 +408,31 @@ ChatRootView {
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)
skillCommandPopup.dismiss()
return
}
}
fileMentionPopup.dismiss()
const slashIndex = textBefore.lastIndexOf('/')
if (slashIndex >= 0) {
const beforeSlash = slashIndex === 0
? ' '
: textBefore.charAt(slashIndex - 1)
const skillQuery = textBefore.substring(slashIndex + 1)
if ((beforeSlash === ' ' || beforeSlash === '\n')
&& /^[a-z0-9-]*$/.test(skillQuery)) {
skillCommandPopup.updateSearch(skillQuery)
return
}
}
skillCommandPopup.dismiss()
}
Keys.onPressed: function(event) {
@@ -398,6 +450,20 @@ ChatRootView {
fileMentionPopup.dismiss()
event.accepted = true
}
} else if (skillCommandPopup.visible) {
if (event.key === Qt.Key_Down) {
skillCommandPopup.moveDown()
event.accepted = true
} else if (event.key === Qt.Key_Up) {
skillCommandPopup.moveUp()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applySkillSelection()
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
skillCommandPopup.dismiss()
event.accepted = true
}
}
}
@@ -509,15 +575,6 @@ ChatRootView {
}
}
Shortcut {
id: sendMessageShortcut
sequences: ["Ctrl+Return", "Ctrl+Enter"]
context: Qt.WindowShortcut
enabled: messageInput.activeFocus && !Qt.inputMethod.visible && !fileMentionPopup.visible
onActivated: root.sendChatMessage()
}
function clearChat() {
root.clearMessages()
root.clearAttachmentFiles()
@@ -528,6 +585,10 @@ ChatRootView {
Qt.callLater(chatListView.positionViewAtEnd)
}
function focusInput() {
messageInput.forceActiveFocus()
}
function applyMentionSelection() {
var result = fileMentionPopup.applyCurrentSelection(
messageInput.text, messageInput.cursorPosition, root.useTools)
@@ -537,6 +598,23 @@ ChatRootView {
}
}
function applySkillSelection() {
const name = skillCommandPopup.currentName()
if (name === "")
return
const cursorPos = messageInput.cursorPosition
const textBefore = messageInput.text.substring(0, cursorPos)
const slashIndex = textBefore.lastIndexOf('/')
if (slashIndex < 0)
return
const before = messageInput.text.substring(0, slashIndex)
const after = messageInput.text.substring(cursorPos)
const token = '/' + name + ' '
messageInput.text = before + token + after
messageInput.cursorPosition = before.length + token.length
skillCommandPopup.dismiss()
}
function sendChatMessage() {
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
messageInput.text = ""
@@ -636,7 +714,21 @@ ChatRootView {
}
}
SkillCommandPopup {
id: skillCommandPopup
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
skillProvider: root
onSelectionRequested: root.applySkillSelection()
}
Component.onCompleted: {
messageInput.forceActiveFocus()
focusInput()
}
}

View File

@@ -30,10 +30,16 @@ Rectangle {
property int textFontSize: Qt.application.font.pointSize
property int codeFontSize: Qt.application.font.pointSize
property int textFormat: 0
property Flickable chatViewport: null
property bool isUserMessage: false
property int messageIndex: -1
property int promptTokens: 0
property int completionTokens: 0
property int cachedPromptTokens: 0
property int reasoningTokens: 0
signal resetChatToMessage(int index)
signal openFileRequested(string filePath)
@@ -135,6 +141,39 @@ Rectangle {
}
}
}
RowLayout {
id: usageBadge
Layout.fillWidth: true
Layout.leftMargin: 10
Layout.rightMargin: 10
spacing: 8
visible: !root.isUserMessage
&& (root.promptTokens > 0 || root.completionTokens > 0)
Item { Layout.fillWidth: true }
Text {
text: root.cachedPromptTokens > 0
? qsTr("↑ %1 (cached %2)").arg(root.promptTokens).arg(root.cachedPromptTokens)
: qsTr("↑ %1").arg(root.promptTokens)
color: palette.placeholderText
font.pointSize: Math.max(root.textFontSize - 2, 7)
}
Text {
text: root.reasoningTokens > 0
? qsTr("↓ %1 (reasoning %2)").arg(root.completionTokens).arg(root.reasoningTokens)
: qsTr("↓ %1").arg(root.completionTokens)
color: palette.placeholderText
font.pointSize: Math.max(root.textFontSize - 2, 7)
}
Text {
text: qsTr("Σ %1").arg(root.promptTokens + root.completionTokens)
color: palette.placeholderText
font.pointSize: Math.max(root.textFontSize - 2, 7)
}
}
}
Rectangle {
@@ -221,6 +260,7 @@ Rectangle {
language: itemData.language
codeFontFamily: root.codeFontFamily
codeFontSize: root.codeFontSize
viewport: root.chatViewport
}
component AttachmentComponent : Rectangle {

View File

@@ -13,6 +13,7 @@ Rectangle {
property string code: ""
property string language: ""
property bool expanded: false
property Flickable viewport: null
property alias codeFontFamily: codeText.font.family
property alias codeFontSize: codeText.font.pointSize
@@ -122,7 +123,16 @@ Rectangle {
anchors.right: parent.right
anchors.rightMargin: 5
y: 5
y: {
if (!root.expanded || !root.viewport)
return 5
const flick = root.viewport
const topInContent = root.mapToItem(flick.contentItem, 0, 0).y
const topInView = topInContent - flick.contentY
const desired = topInView < 0 ? (-topInView + 5) : 5
const maxY = Math.max(5, root.height - copyButton.height - 5)
return Math.max(5, Math.min(desired, maxY))
}
text: qsTr("Copy")
onClicked: {

View File

@@ -0,0 +1,125 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Rectangle {
id: root
// Object exposing Q_INVOKABLE QVariantList searchSkills(query).
property var skillProvider: null
property var searchResults: []
property int currentIndex: 0
signal selectionRequested()
visible: searchResults.length > 0
height: Math.min(searchResults.length * 40, 40 * 6) + 2
color: palette.window
border.color: palette.mid
border.width: 1
radius: 4
function updateSearch(query) {
searchResults = skillProvider ? skillProvider.searchSkills(query) : []
currentIndex = 0
}
function dismiss() {
searchResults = []
currentIndex = 0
}
function moveUp() {
if (currentIndex > 0)
currentIndex--
}
function moveDown() {
if (currentIndex < searchResults.length - 1)
currentIndex++
}
function currentName() {
if (currentIndex >= 0 && currentIndex < searchResults.length)
return searchResults[currentIndex].name
return ""
}
onCurrentIndexChanged: listView.positionViewAtIndex(currentIndex, ListView.Contain)
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
width: listView.width
height: 40
color: index === root.currentIndex
? palette.highlight
: (hoverArea.containsMouse
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25)
: "transparent")
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 1
Text {
Layout.fillWidth: true
text: "/" + delegateItem.modelData.name
color: delegateItem.index === root.currentIndex
? palette.highlightedText
: palette.text
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: delegateItem.modelData.description
color: delegateItem.index === root.currentIndex
? Qt.rgba(palette.highlightedText.r,
palette.highlightedText.g,
palette.highlightedText.b, 0.7)
: palette.mid
font.pixelSize: 11
elide: Text.ElideRight
}
}
MouseArea {
id: hoverArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
root.currentIndex = delegateItem.index
root.selectionRequested()
}
onEntered: root.currentIndex = delegateItem.index
}
}
}
}

View File

@@ -10,13 +10,17 @@ import UIControls
Rectangle {
id: root
property bool isInEditor: false
property alias saveButton: saveButtonId
property alias loadButton: loadButtonId
property alias clearButton: clearButtonId
property alias newChatButton: newChatButtonId
property alias tokensBadge: tokensBadgeId
property alias recentPath: recentPathId
property alias openChatHistory: openChatHistoryId
property alias pinButton: pinButtonId
property alias relocateButton: relocateButtonId
property alias contextButton: contextButtonId
property alias toolsButton: toolsButtonId
property alias thinkingMode: thinkingModeId
@@ -61,6 +65,58 @@ Rectangle {
: qsTr("Pin chat window to the top")
}
QoAButton {
id: relocateButtonId
anchors.verticalCenter: parent.verticalCenter
icon {
source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
}
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
QoAButton {
id: clearButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Clean chat")
}
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
QoAButton {
id: newChatButtonId
visible: root.isInEditor
icon {
source: "qrc:/qt/qml/ChatView/icons/new-chat-icon.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Open new chat in a new tab")
}
QoAComboBox {
id: configSelectorId
@@ -260,21 +316,6 @@ Rectangle {
ToolTip.delay: 250
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
}
QoASeparator {}
QoAButton {
id: clearButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Clean chat")
}
}
}
}

View File

@@ -63,6 +63,21 @@ void LLMClientInterface::handleFullResponse(const QString &requestId, const QStr
m_performanceLogger.endTimeMeasurement(requestId);
}
void LLMClientInterface::handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (!m_activeRequests.contains(requestId) || !info.usage)
return;
const auto &u = *info.usage;
LOG_MESSAGE(QString("Completion usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(u.promptTokens)
.arg(u.completionTokens)
.arg(u.cachedPromptTokens)
.arg(u.reasoningTokens));
}
void LLMClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
{
auto it = m_activeRequests.find(requestId);
@@ -325,6 +340,12 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
this,
&LLMClientInterface::handleFullResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFinalized,
this,
&LLMClientInterface::handleRequestFinalized,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFailed,

View File

@@ -3,6 +3,7 @@
#pragma once
#include <LLMQore/BaseClient.hpp>
#include <languageclient/languageclientinterface.h>
#include <texteditor/texteditor.h>
@@ -52,6 +53,8 @@ protected:
private slots:
void handleFullResponse(const QString &requestId, const QString &fullText);
void handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleRequestFailed(const QString &requestId, const QString &error);
private:

View File

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

View File

@@ -10,4 +10,16 @@ const char MENU_ID[] = "QodeAssist.Menu";
const char QODE_ASSIST_REQUEST_SUGGESTION[] = "QodeAssist.RequestSuggestion";
const char QODE_ASSIST_CHAT_CONTEXT[] = "QodeAssist.ChatContext";
const char QODE_ASSIST_CHAT_NAV_ID[] = "QodeAssistChat";
const char QODE_ASSIST_CHAT_EDITOR_ID[] = "QodeAssist.ChatEditor";
const char QODE_ASSIST_SHOW_CHAT_ACTION[] = "QodeAssist.ShowChatView";
const char QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION[] = "QodeAssist.OpenChatWindow";
const char QODE_ASSIST_NEW_CHAT_ACTION[] = "QodeAssist.NewChat";
const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage";
const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession";
const char QODE_ASSIST_CHAT_SHOW_IN_RIGHT[] = "QodeAssist.Chat.ShowInRightSidebar";
} // namespace QodeAssist::Constants

View File

@@ -152,6 +152,13 @@ void QuickRefactorHandler::prepareAndSendRequest(
&QuickRefactorHandler::handleFullResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFinalized,
this,
&QuickRefactorHandler::handleRequestFinalized,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFailed,
@@ -408,6 +415,22 @@ void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QS
}
}
void QuickRefactorHandler::handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (requestId != m_lastRequestId || !info.usage)
return;
const auto &u = *info.usage;
LOG_MESSAGE(
QString("Quick refactor usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(u.promptTokens)
.arg(u.completionTokens)
.arg(u.cachedPromptTokens)
.arg(u.reasoningTokens));
}
void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error)
{
if (requestId == m_lastRequestId) {

View File

@@ -6,6 +6,7 @@
#include <QJsonObject>
#include <QObject>
#include <LLMQore/BaseClient.hpp>
#include <texteditor/texteditor.h>
#include <utils/textutils.h>
@@ -43,6 +44,8 @@ signals:
private slots:
void handleFullResponse(const QString &requestId, const QString &fullText);
void handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleRequestFailed(const QString &requestId, const QString &error);
private:

View File

@@ -35,6 +35,7 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
- **Chat Assistant** — side panel, bottom panel, or detached window; history with auto-save, token monitoring, extended thinking
- **Quick Refactoring** — inline AI-assisted edits directly in the editor with a searchable custom-instructions library
- **Agent Tools** — read, search, create and edit files; build the project; run terminal commands; access linter/compiler issues; manage TODOs
- **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on
- **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge)
- **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet)
- **File Context** — attach, link, or auto-sync open editor files for richer prompts
@@ -253,6 +254,41 @@ Chat and Quick Refactor can call tools to inspect and modify your project. Each
| `execute_terminal_command` | Run a shell command (with confirmation) |
| `todo_tool` | Track multi-step task progress during a conversation |
### Skills
**Agent Skills** package specialized instructions and workflows into reusable folders the AI loads on demand. QodeAssist implements the open [Agent Skills](https://agentskills.io) format, so skills authored for Claude Code, Cursor, or other agents work as-is.
A skill is a folder containing a `SKILL.md` file — YAML frontmatter (`name`, `description`) plus Markdown instructions:
```
my-skill/
└── SKILL.md
```
```markdown
---
name: my-skill
description: What the skill does and when to use it.
---
# My Skill
Step-by-step instructions for the task...
```
**Where skills are discovered:**
- **Project skills** — project-relative subdirectories (default `.qodeassist/skills/` and `.claude/skills/`), configured in `Projects → QodeAssist → Skills`. Project skills win over global ones on a name collision.
- **Global skills** — absolute directories shared across all projects (default includes `~/.claude/skills/`), configured in `Tools → Options → QodeAssist → Skills`.
Both settings pages show the list of currently discovered skills.
**How skills are used in Chat:**
- **Automatically** — each skill's name and description is added to the system prompt; when a request matches, the model loads the full instructions via the `load_skill` tool (requires a tool-calling model).
- **Explicitly** — type `/` in the chat input and pick a skill from the popup; its instructions are injected into that one message. Works with any model.
- **Always-on** — a skill whose frontmatter has `metadata: always-on: "true"` is injected into every chat request automatically.
Enable or disable the whole feature in `Tools → Options → QodeAssist → Skills`.
### MCP Server
QodeAssist can run an **MCP (Model Context Protocol) server** on `localhost`, exposing the tools above to external clients — so you can use QodeAssist's project awareness from Claude Code CLI, VS Code, Cursor, Claude Desktop, or any other MCP-capable client.
@@ -454,6 +490,7 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
- [x] Quick refactoring with custom-instructions library
- [x] Diff sharing with models
- [x] Tools / function calling (file I/O, build, terminal, diagnostics)
- [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool)
- [x] Project-specific rules (`.qodeassist/rules/`)
- [x] MCP (Model Context Protocol) — QodeAssist as a server
- [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported)
@@ -470,6 +507,7 @@ If you find QodeAssist helpful, there are several ways you can support the proje
3. **Spread the Word**: Star our GitHub repository and share QodeAssist with your fellow developers.
4. **Financial Support**: If you'd like to support the development financially, you can make a donation using one of the following:
- Paypal: [my paypalme page](https://www.paypal.com/paypalme/palm1r)
- Bitcoin (BTC): `bc1qndq7f0mpnlya48vk7kugvyqj5w89xrg4wzg68t`
- Ethereum (ETH): `0xA5e8c37c94b24e25F9f1f292a01AF55F03099D8D`
- Litecoin (LTC): `ltc1qlrxnk30s2pcjchzx4qrxvdjt5gzuervy5mv0vy`

View File

@@ -3,6 +3,8 @@
#include "UpdateStatusWidget.hpp"
#include <QMenu>
namespace QodeAssist {
UpdateStatusWidget::UpdateStatusWidget(QWidget *parent)
@@ -57,6 +59,16 @@ void UpdateStatusWidget::setChatButtonAction(QAction *action)
m_chatButton->setDefaultAction(action);
}
void UpdateStatusWidget::setChatButtonMenu(QMenu *menu)
{
m_chatButton->setMenu(menu);
m_chatButton->setPopupMode(QToolButton::DelayedPopup);
m_chatButton->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_chatButton, &QWidget::customContextMenuRequested, m_chatButton, [this, menu](const QPoint &pos) {
menu->exec(m_chatButton->mapToGlobal(pos));
});
}
QPushButton *UpdateStatusWidget::updateButton() const
{
return m_updateButton;

View File

@@ -9,6 +9,10 @@
#include <QPushButton>
#include <QToolButton>
QT_BEGIN_NAMESPACE
class QMenu;
QT_END_NAMESPACE
namespace QodeAssist {
class UpdateStatusWidget : public QFrame
@@ -21,6 +25,7 @@ public:
void showUpdateAvailable(const QString &version);
void hideUpdateInfo();
void setChatButtonAction(QAction *action);
void setChatButtonMenu(QMenu *menu);
QPushButton *updateButton() const;

47
chat/ChatDocument.cpp Normal file
View File

@@ -0,0 +1,47 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatDocument.hpp"
#include <utils/result.h>
#include "QodeAssistConstants.hpp"
#include "QodeAssisttr.h"
namespace QodeAssist::Chat {
ChatDocument::ChatDocument(QObject *parent)
: Core::IDocument(parent)
{
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
setMimeType("text/plain");
setTemporary(true);
setPreferredDisplayName(Tr::tr("QodeAssist Chat"));
}
QByteArray ChatDocument::contents() const
{
return {};
}
Utils::Result<> ChatDocument::setContents(const QByteArray &)
{
return Utils::ResultOk;
}
bool ChatDocument::isModified() const
{
return false;
}
bool ChatDocument::isSaveAsAllowed() const
{
return false;
}
bool ChatDocument::shouldAutoSave() const
{
return false;
}
} // namespace QodeAssist::Chat

27
chat/ChatDocument.hpp Normal file
View File

@@ -0,0 +1,27 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <coreplugin/idocument.h>
namespace QodeAssist::Chat {
// Backing document for a chat editor view. The chat persists itself through its own
// autosave history file, so this document is purely a placeholder for the editor area
// and never participates in Qt Creator's save infrastructure.
class ChatDocument : public Core::IDocument
{
Q_OBJECT
public:
explicit ChatDocument(QObject *parent = nullptr);
QByteArray contents() const override;
Utils::Result<> setContents(const QByteArray &contents) override;
bool isModified() const override;
bool isSaveAsAllowed() const override;
bool shouldAutoSave() const override;
};
} // namespace QodeAssist::Chat

75
chat/ChatEditor.cpp Normal file
View File

@@ -0,0 +1,75 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatEditor.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include "ChatDocument.hpp"
#include "ChatView/ChatRootView.hpp"
#include "ChatView/ChatWidget.hpp"
#include "QodeAssistConstants.hpp"
#include "QodeAssisttr.h"
namespace QodeAssist::Chat {
ChatEditor::ChatEditor(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager)
: m_engine(engine)
, m_sessionFileRegistry(sessionFileRegistry)
, m_skillsManager(skillsManager)
, m_document(new ChatDocument(this))
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager, false))
{
setWidget(m_chatWidget);
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
setDuplicateSupported(false);
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject())) {
rootView->setInEditor(true);
connect(
rootView,
&ChatRootView::closeHostRequested,
this,
[this] { Core::EditorManager::closeEditors({this}); },
Qt::QueuedConnection);
auto syncTitle = [this, rootView] {
const QString title = rootView->chatTitle();
m_document->setPreferredDisplayName(
title.isEmpty() ? Tr::tr("QodeAssist Chat") : QStringLiteral("QodeAssist - ") + title);
};
connect(rootView, &ChatRootView::chatTitleChanged, this, syncTitle);
syncTitle();
}
}
void ChatEditor::consumePendingChatFile()
{
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject()))
rootView->consumePendingChatFile();
}
ChatEditor::~ChatEditor()
{
delete m_chatWidget;
}
Core::IDocument *ChatEditor::document() const
{
return m_document;
}
QWidget *ChatEditor::toolBar()
{
return nullptr;
}
Core::IEditor *ChatEditor::duplicate()
{
return nullptr;
}
} // namespace QodeAssist::Chat

45
chat/ChatEditor.hpp Normal file
View File

@@ -0,0 +1,45 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <coreplugin/editormanager/ieditor.h>
class QQmlEngine;
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ChatDocument;
class ChatWidget;
class SessionFileRegistry;
class ChatEditor : public Core::IEditor
{
Q_OBJECT
public:
ChatEditor(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager);
~ChatEditor() override;
Core::IDocument *document() const override;
QWidget *toolBar() override;
Core::IEditor *duplicate() override;
void consumePendingChatFile();
private:
QQmlEngine *m_engine;
SessionFileRegistry *m_sessionFileRegistry;
Skills::SkillsManager *m_skillsManager;
ChatDocument *m_document;
ChatWidget *m_chatWidget;
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,24 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatEditorFactory.hpp"
#include "ChatEditor.hpp"
#include "QodeAssistConstants.hpp"
#include "QodeAssisttr.h"
namespace QodeAssist::Chat {
ChatEditorFactory::ChatEditorFactory(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager)
{
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
setDisplayName(Tr::tr("QodeAssist Chat"));
setEditorCreator([engine, sessionFileRegistry, skillsManager] {
return new ChatEditor(engine, sessionFileRegistry, skillsManager);
});
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,27 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <coreplugin/editormanager/ieditorfactory.h>
class QQmlEngine;
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class ChatEditorFactory : public Core::IEditorFactory
{
public:
ChatEditorFactory(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager);
};
} // namespace QodeAssist::Chat

View File

@@ -7,9 +7,13 @@
namespace QodeAssist::Chat {
ChatOutputPane::ChatOutputPane(QObject *parent)
ChatOutputPane::ChatOutputPane(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager,
QObject *parent)
: Core::IOutputPane(parent)
, m_chatWidget(new ChatWidget)
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry, skillsManager}}
{
setId("QodeAssistChat");
setDisplayName(Tr::tr("QodeAssist Chat"));
@@ -38,18 +42,20 @@ void ChatOutputPane::clearContents()
void ChatOutputPane::visibilityChanged(bool visible)
{
if (visible)
if (visible) {
m_chatWidget->scrollToBottom();
m_chatWidget->focusInput();
}
}
void ChatOutputPane::setFocus()
{
m_chatWidget->setFocus();
m_chatWidget->focusInput();
}
bool ChatOutputPane::hasFocus() const
{
return m_chatWidget->hasFocus();
return m_chatWidget->isChatFocused();
}
bool ChatOutputPane::canFocus() const

View File

@@ -6,14 +6,24 @@
#include "ChatView/ChatWidget.hpp"
#include <coreplugin/ioutputpane.h>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class ChatOutputPane : public Core::IOutputPane
{
Q_OBJECT
public:
explicit ChatOutputPane(QObject *parent = nullptr);
explicit ChatOutputPane(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager,
QObject *parent = nullptr);
~ChatOutputPane() override;
QWidget *outputWidget(QWidget *parent) override;

View File

@@ -4,14 +4,23 @@
#include "NavigationPanel.hpp"
#include "ChatView/ChatWidget.hpp"
#include "ChatView/SessionFileRegistry.hpp"
#include "QodeAssistConstants.hpp"
#include "sources/skills/SkillsManager.hpp"
namespace QodeAssist::Chat {
NavigationPanel::NavigationPanel()
NavigationPanel::NavigationPanel(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager)
: m_engine{engine}
, m_sessionFileRegistry{sessionFileRegistry}
, m_skillsManager{skillsManager}
{
setDisplayName(tr("QodeAssist Chat"));
setPriority(500);
setId("QodeAssistChat");
setId(Constants::QODE_ASSIST_CHAT_NAV_ID);
setActivationSequence(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C));
}
@@ -19,10 +28,7 @@ NavigationPanel::~NavigationPanel() {}
Core::NavigationView NavigationPanel::createWidget()
{
Core::NavigationView view;
view.widget = new ChatWidget;
return view;
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry, m_skillsManager}};
}
} // namespace QodeAssist::Chat

View File

@@ -5,17 +5,34 @@
#include <coreplugin/inavigationwidgetfactory.h>
#include <QObject>
#include <QPointer>
class QQmlEngine;
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class NavigationPanel : public Core::INavigationWidgetFactory
{
Q_OBJECT
public:
explicit NavigationPanel();
explicit NavigationPanel(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager);
~NavigationPanel();
Core::NavigationView createWidget() override;
private:
QPointer<QQmlEngine> m_engine;
QPointer<SessionFileRegistry> m_sessionFileRegistry;
QPointer<Skills::SkillsManager> m_skillsManager;
};
} // namespace QodeAssist::Chat

View File

@@ -15,6 +15,7 @@ add_library(Context STATIC
target_link_libraries(Context
PUBLIC
Qt::Core
Qt::Gui
QtCreator::Core
QtCreator::TextEditor
QtCreator::Utils

View File

@@ -3,6 +3,14 @@
#include "TokenUtils.hpp"
#include <QFileInfo>
#include <QImageReader>
#include <QSet>
#include <QSize>
#include <algorithm>
#include <cmath>
namespace QodeAssist::Context {
int TokenUtils::estimateTokens(const QString &text)
@@ -15,8 +23,48 @@ int TokenUtils::estimateTokens(const QString &text)
return text.length() / 4;
}
bool TokenUtils::isImageFilePath(const QString &filePath)
{
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp"};
return imageExtensions.contains(QFileInfo(filePath).suffix().toLower());
}
int TokenUtils::estimateImageAttachmentTokens(const QString &filePath)
{
QImageReader reader(filePath);
QSize size = reader.size();
if (!size.isValid() || size.isEmpty())
return 1500;
double w = size.width();
double h = size.height();
const double longSide = std::max(w, h);
if (longSide > 2048.0) {
const double s = 2048.0 / longSide;
w *= s;
h *= s;
}
const double shortSide = std::min(w, h);
if (shortSide > 768.0) {
const double s = 768.0 / shortSide;
w *= s;
h *= s;
}
const int tilesW = static_cast<int>(std::ceil(w / 512.0));
const int tilesH = static_cast<int>(std::ceil(h / 512.0));
const int tiles = std::max(1, tilesW * tilesH);
return 85 + tiles * 170;
}
int TokenUtils::estimateFileTokens(const Context::ContentFile &file)
{
if (isImageFilePath(file.filename))
return estimateImageAttachmentTokens(QString());
int total = 0;
total += estimateTokens(file.filename);

View File

@@ -15,6 +15,8 @@ public:
static int estimateTokens(const QString &text);
static int estimateFileTokens(const Context::ContentFile &file);
static int estimateFilesTokens(const QList<Context::ContentFile> &files);
static bool isImageFilePath(const QString &filePath);
static int estimateImageAttachmentTokens(const QString &filePath);
};
} // namespace QodeAssist::Context

View File

@@ -1,5 +1,4 @@
add_library(QodeAssistLogger STATIC
EmptyRequestPerformanceLogger.hpp
IRequestPerformanceLogger.hpp
Logger.cpp
Logger.hpp

View File

@@ -1,16 +0,0 @@
// Copyright (C) 2025 Povilas Kanapickas <povilas@radix.lt>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "IRequestPerformanceLogger.hpp"
namespace QodeAssist {
class EmptyRequestPerformanceLogger : public IRequestPerformanceLogger
{
public:
void startTimeMeasurement(const QString &requestId) override {}
void endTimeMeasurement(const QString &requestId) override {}
void logPerformance(const QString &requestId, qint64 elapsedMs) override {}
};
} // namespace QodeAssist

View File

@@ -189,19 +189,42 @@ QList<PluginLLMCore::Provider *> McpClientsManager::toolsCapableProviders() cons
return out;
}
QJsonObject McpClientsManager::builtinServers()
{
static const QByteArray pseudoConfig(
"{\n"
" \"mcpServers\": {\n"
" \"qt-docs\": {\n"
" \"type\": \"sse\",\n"
" \"url\": \"https://qt-docs-mcp.qt.io/mcp\",\n"
" \"enable\": false\n"
" }\n"
" }\n"
"}\n");
const QJsonDocument doc = QJsonDocument::fromJson(pseudoConfig);
return doc.object().value(QLatin1String(kServersKey)).toObject();
}
QJsonObject McpClientsManager::readRoot() const
{
QJsonObject root{{QLatin1String(kServersKey), QJsonObject{}}};
QFile f(configFilePath());
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
QJsonParseError err;
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
f.close();
if (err.error != QJsonParseError::NoError || !doc.isObject())
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
QJsonObject root = doc.object();
if (!root.contains(QLatin1String(kServersKey)))
root.insert(QLatin1String(kServersKey), QJsonObject{});
if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
QJsonParseError err;
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
f.close();
if (err.error == QJsonParseError::NoError && doc.isObject())
root = doc.object();
}
QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
const QJsonObject builtin = builtinServers();
for (auto it = builtin.begin(); it != builtin.end(); ++it) {
if (!servers.contains(it.key()))
servers.insert(it.key(), it.value());
}
root.insert(QLatin1String(kServersKey), servers);
return root;
}

View File

@@ -50,6 +50,7 @@ private:
void updateWatchedPaths();
QList<PluginLLMCore::Provider *> toolsCapableProviders() const;
static QJsonObject builtinServers();
QJsonObject readRoot() const;
bool writeRoot(const QJsonObject &root);

View File

@@ -10,10 +10,6 @@ add_library(PluginLLMCore STATIC
PromptTemplate.hpp
PromptTemplateManager.hpp PromptTemplateManager.cpp
ProviderID.hpp
HttpClient.hpp HttpClient.cpp
DataBuffers.hpp
SSEBuffer.hpp SSEBuffer.cpp
ContentBlocks.hpp
RulesLoader.hpp RulesLoader.cpp
ResponseCleaner.hpp
)

View File

@@ -1,236 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QObject>
#include <QString>
namespace QodeAssist::PluginLLMCore {
enum class MessageState { Building, Complete, RequiresToolExecution, Final };
enum class ProviderFormat { Claude, OpenAI };
class ContentBlock : public QObject
{
Q_OBJECT
public:
explicit ContentBlock(QObject *parent = nullptr)
: QObject(parent)
{}
virtual ~ContentBlock() = default;
virtual QString type() const = 0;
virtual QJsonValue toJson(ProviderFormat format) const = 0;
};
class TextContent : public ContentBlock
{
Q_OBJECT
public:
explicit TextContent(const QString &text = QString())
: ContentBlock()
, m_text(text)
{}
QString type() const override { return "text"; }
QString text() const { return m_text; }
void appendText(const QString &text) { m_text += text; }
void setText(const QString &text) { m_text = text; }
QJsonValue toJson(ProviderFormat format) const override
{
Q_UNUSED(format);
return QJsonObject{{"type", "text"}, {"text", m_text}};
}
private:
QString m_text;
};
class ImageContent : public ContentBlock
{
Q_OBJECT
public:
enum class ImageSourceType { Base64, Url };
ImageContent(const QString &data, const QString &mediaType, ImageSourceType sourceType = ImageSourceType::Base64)
: ContentBlock()
, m_data(data)
, m_mediaType(mediaType)
, m_sourceType(sourceType)
{}
QString type() const override { return "image"; }
QString data() const { return m_data; }
QString mediaType() const { return m_mediaType; }
ImageSourceType sourceType() const { return m_sourceType; }
QJsonValue toJson(ProviderFormat format) const override
{
if (format == ProviderFormat::Claude) {
QJsonObject source;
if (m_sourceType == ImageSourceType::Base64) {
source["type"] = "base64";
source["media_type"] = m_mediaType;
source["data"] = m_data;
} else {
source["type"] = "url";
source["url"] = m_data;
}
return QJsonObject{{"type", "image"}, {"source", source}};
} else { // OpenAI format
QJsonObject imageUrl;
if (m_sourceType == ImageSourceType::Base64) {
imageUrl["url"] = QString("data:%1;base64,%2").arg(m_mediaType, m_data);
} else {
imageUrl["url"] = m_data;
}
return QJsonObject{{"type", "image_url"}, {"image_url", imageUrl}};
}
}
private:
QString m_data;
QString m_mediaType;
ImageSourceType m_sourceType;
};
class ToolUseContent : public ContentBlock
{
Q_OBJECT
public:
ToolUseContent(const QString &id, const QString &name, const QJsonObject &input = QJsonObject())
: ContentBlock()
, m_id(id)
, m_name(name)
, m_input(input)
{}
QString type() const override { return "tool_use"; }
QString id() const { return m_id; }
QString name() const { return m_name; }
QJsonObject input() const { return m_input; }
void setInput(const QJsonObject &input) { m_input = input; }
QJsonValue toJson(ProviderFormat format) const override
{
if (format == ProviderFormat::Claude) {
return QJsonObject{
{"type", "tool_use"}, {"id", m_id}, {"name", m_name}, {"input", m_input}};
} else { // OpenAI
QJsonDocument doc(m_input);
return QJsonObject{
{"id", m_id},
{"type", "function"},
{"function",
QJsonObject{
{"name", m_name},
{"arguments", QString::fromUtf8(doc.toJson(QJsonDocument::Compact))}}}};
}
}
private:
QString m_id;
QString m_name;
QJsonObject m_input;
};
class ToolResultContent : public ContentBlock
{
Q_OBJECT
public:
ToolResultContent(const QString &toolUseId, const QString &result)
: ContentBlock()
, m_toolUseId(toolUseId)
, m_result(result)
{}
QString type() const override { return "tool_result"; }
QString toolUseId() const { return m_toolUseId; }
QString result() const { return m_result; }
QJsonValue toJson(ProviderFormat format) const override
{
if (format == ProviderFormat::Claude) {
return QJsonObject{
{"type", "tool_result"}, {"tool_use_id", m_toolUseId}, {"content", m_result}};
} else { // OpenAI
return QJsonObject{{"role", "tool"}, {"tool_call_id", m_toolUseId}, {"content", m_result}};
}
}
private:
QString m_toolUseId;
QString m_result;
};
class ThinkingContent : public ContentBlock
{
Q_OBJECT
public:
explicit ThinkingContent(const QString &thinking = QString(), const QString &signature = QString())
: ContentBlock()
, m_thinking(thinking)
, m_signature(signature)
{}
QString type() const override { return "thinking"; }
QString thinking() const { return m_thinking; }
QString signature() const { return m_signature; }
void appendThinking(const QString &text) { m_thinking += text; }
void setThinking(const QString &text) { m_thinking = text; }
void setSignature(const QString &signature) { m_signature = signature; }
QJsonValue toJson(ProviderFormat format) const override
{
Q_UNUSED(format);
// Only include signature field if it's not empty
// Empty signature is rejected by API with "Invalid signature" error
// In streaming mode, signature is not provided, so we omit the field entirely
QJsonObject obj{{"type", "thinking"}, {"thinking", m_thinking}};
if (!m_signature.isEmpty()) {
obj["signature"] = m_signature;
}
return obj;
}
private:
QString m_thinking;
QString m_signature;
};
class RedactedThinkingContent : public ContentBlock
{
Q_OBJECT
public:
explicit RedactedThinkingContent(const QString &signature = QString())
: ContentBlock()
, m_signature(signature)
{}
QString type() const override { return "redacted_thinking"; }
QString signature() const { return m_signature; }
void setSignature(const QString &signature) { m_signature = signature; }
QJsonValue toJson(ProviderFormat format) const override
{
Q_UNUSED(format);
// Only include signature field if it's not empty
// Empty signature is rejected by API with "Invalid signature" error
QJsonObject obj{{"type", "redacted_thinking"}};
if (!m_signature.isEmpty()) {
obj["signature"] = m_signature;
}
return obj;
}
private:
QString m_signature;
};
} // namespace QodeAssist::PluginLLMCore

View File

@@ -3,6 +3,7 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <QVector>
@@ -17,6 +18,15 @@ struct ImageAttachment
bool operator==(const ImageAttachment &) const = default;
};
struct ToolCall
{
QString id;
QString name;
QJsonObject arguments;
bool operator==(const ToolCall &) const = default;
};
struct Message
{
QString role;
@@ -26,6 +36,10 @@ struct Message
bool isRedacted = false;
std::optional<QVector<ImageAttachment>> images;
QVector<ToolCall> toolCalls;
QString toolCallId;
QString toolName;
// clang-format off
bool operator==(const Message&) const = default;
// clang-format on

View File

@@ -1,23 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "SSEBuffer.hpp"
#include <QString>
namespace QodeAssist::PluginLLMCore {
struct DataBuffers
{
SSEBuffer rawStreamBuffer;
QString responseContent;
void clear()
{
rawStreamBuffer.clear();
responseContent.clear();
}
};
} // namespace QodeAssist::PluginLLMCore

View File

@@ -1,260 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "HttpClient.hpp"
#include <QJsonDocument>
#include <QMutexLocker>
#include <Logger.hpp>
namespace QodeAssist::PluginLLMCore {
HttpClient::HttpClient(QObject *parent)
: QObject(parent)
, m_manager(new QNetworkAccessManager(this))
{}
HttpClient::~HttpClient()
{
QMutexLocker locker(&m_mutex);
for (auto *reply : std::as_const(m_activeRequests)) {
reply->abort();
reply->deleteLater();
}
m_activeRequests.clear();
}
QFuture<QByteArray> HttpClient::get(const QNetworkRequest &request)
{
LOG_MESSAGE(QString("HttpClient: GET %1").arg(request.url().toString()));
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::onStreamingFinished);
}
void HttpClient::cancelRequest(const QString &requestId)
{
QMutexLocker locker(&m_mutex);
auto it = m_activeRequests.find(requestId);
if (it != m_activeRequests.end()) {
QNetworkReply *reply = it.value();
if (reply) {
reply->disconnect();
reply->abort();
reply->deleteLater();
}
m_activeRequests.erase(it);
LOG_MESSAGE(QString("HttpClient: Cancelled request: %1").arg(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::PluginLLMCore

View File

@@ -1,60 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <optional>
#include <QFuture>
#include <QHash>
#include <QJsonObject>
#include <QMutex>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QObject>
#include <QPromise>
namespace QodeAssist::PluginLLMCore {
class HttpClient : public QObject
{
Q_OBJECT
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 dataReceived(const QString &requestId, const QByteArray &data);
void requestFinished(const QString &requestId, std::optional<QString> error);
private slots:
void onReadyRead();
void onStreamingFinished();
private:
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;
mutable QMutex m_mutex;
};
} // namespace QodeAssist::PluginLLMCore

View File

@@ -25,12 +25,8 @@ public:
virtual QString description() const = 0;
virtual bool isSupportProvider(ProviderID id) const = 0;
// Endpoint path this template expects to be sent to. Empty string
// (default) means "let the provider's client use its standard chat
// path" (/chat/completions, /api/chat, /v1/messages, ...). Templates
// producing non-chat payload shapes (e.g. {prompt, suffix} for
// Mistral FIM, {input_prefix, input_suffix} for llama.cpp infill)
// must override this to the path their payload is valid for.
virtual QString endpoint() const { return {}; }
virtual bool supportsToolHistory() const { return false; }
};
} // namespace QodeAssist::PluginLLMCore

View File

@@ -1,35 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SSEBuffer.hpp"
namespace QodeAssist::PluginLLMCore {
QStringList SSEBuffer::processData(const QByteArray &data)
{
m_buffer += QString::fromUtf8(data);
QStringList lines = m_buffer.split('\n');
m_buffer = lines.takeLast();
lines.removeAll(QString());
return lines;
}
void SSEBuffer::clear()
{
m_buffer.clear();
}
QString SSEBuffer::currentBuffer() const
{
return m_buffer;
}
bool SSEBuffer::hasIncompleteData() const
{
return !m_buffer.isEmpty();
}
} // namespace QodeAssist::PluginLLMCore

View File

@@ -1,26 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <QStringList>
namespace QodeAssist::PluginLLMCore {
class SSEBuffer
{
public:
SSEBuffer() = default;
QStringList processData(const QByteArray &data);
void clear();
QString currentBuffer() const;
bool hasIncompleteData() const;
private:
QString m_buffer;
};
} // namespace QodeAssist::PluginLLMCore

View File

@@ -0,0 +1,89 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QString>
namespace QodeAssist::Providers::ClaudeCacheControl {
inline QJsonObject buildBreakpoint(bool extendedTtl)
{
QJsonObject cacheControl{{"type", "ephemeral"}};
if (extendedTtl)
cacheControl["ttl"] = "1h";
return cacheControl;
}
inline void markLastBlock(QJsonArray &blocks, const QJsonObject &cacheControl)
{
if (blocks.isEmpty())
return;
QJsonObject last = blocks.last().toObject();
last["cache_control"] = cacheControl;
blocks.replace(blocks.size() - 1, last);
}
inline void applyToSystem(QJsonObject &request, const QJsonObject &cacheControl)
{
if (!request.contains("system"))
return;
const QJsonValue sys = request.value("system");
if (sys.isString()) {
const QString text = sys.toString();
if (!text.isEmpty()) {
request["system"] = QJsonArray{QJsonObject{
{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}};
}
} else if (sys.isArray()) {
QJsonArray blocks = sys.toArray();
markLastBlock(blocks, cacheControl);
request["system"] = blocks;
}
}
inline void applyToTools(QJsonObject &request, const QJsonObject &cacheControl)
{
if (!request.contains("tools"))
return;
QJsonArray tools = request.value("tools").toArray();
markLastBlock(tools, cacheControl);
request["tools"] = tools;
}
inline void applyToHistory(QJsonObject &request, const QJsonObject &cacheControl)
{
if (!request.contains("messages"))
return;
QJsonArray messages = request.value("messages").toArray();
if (messages.size() < 2)
return;
const int idx = messages.size() - 2;
QJsonObject msg = messages[idx].toObject();
const QJsonValue content = msg.value("content");
if (content.isString()) {
msg["content"] = QJsonArray{QJsonObject{
{"type", "text"}, {"text", content.toString()}, {"cache_control", cacheControl}}};
} else if (content.isArray()) {
QJsonArray blocks = content.toArray();
markLastBlock(blocks, cacheControl);
msg["content"] = blocks;
}
messages.replace(idx, msg);
request["messages"] = messages;
}
inline void apply(QJsonObject &request, bool extendedTtl)
{
const QJsonObject cacheControl = buildBreakpoint(extendedTtl);
applyToSystem(request, cacheControl);
applyToTools(request, cacheControl);
applyToHistory(request, cacheControl);
}
} // namespace QodeAssist::Providers::ClaudeCacheControl

View File

@@ -1,223 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ClaudeMessage.hpp"
#include "logger/Logger.hpp"
#include <QJsonArray>
#include <QJsonDocument>
namespace QodeAssist {
ClaudeMessage::ClaudeMessage(QObject *parent)
: QObject(parent)
{}
void ClaudeMessage::handleContentBlockStart(
int index, const QString &blockType, const QJsonObject &data)
{
LOG_MESSAGE(QString("ClaudeMessage: handleContentBlockStart index=%1, blockType=%2")
.arg(index)
.arg(blockType));
if (blockType == "text") {
addCurrentContent<PluginLLMCore::TextContent>();
} else if (blockType == "image") {
QJsonObject source = data["source"].toObject();
QString sourceType = source["type"].toString();
QString imageData;
QString mediaType;
PluginLLMCore::ImageContent::ImageSourceType imgSourceType = PluginLLMCore::ImageContent::ImageSourceType::Base64;
if (sourceType == "base64") {
imageData = source["data"].toString();
mediaType = source["media_type"].toString();
imgSourceType = PluginLLMCore::ImageContent::ImageSourceType::Base64;
} else if (sourceType == "url") {
imageData = source["url"].toString();
imgSourceType = PluginLLMCore::ImageContent::ImageSourceType::Url;
}
addCurrentContent<PluginLLMCore::ImageContent>(imageData, mediaType, imgSourceType);
} else if (blockType == "tool_use") {
QString toolId = data["id"].toString();
QString toolName = data["name"].toString();
QJsonObject toolInput = data["input"].toObject();
addCurrentContent<PluginLLMCore::ToolUseContent>(toolId, toolName, toolInput);
m_pendingToolInputs[index] = "";
} else if (blockType == "thinking") {
QString thinking = data["thinking"].toString();
QString signature = data["signature"].toString();
LOG_MESSAGE(QString("ClaudeMessage: Creating thinking block with signature length=%1")
.arg(signature.length()));
addCurrentContent<PluginLLMCore::ThinkingContent>(thinking, signature);
} else if (blockType == "redacted_thinking") {
QString signature = data["signature"].toString();
LOG_MESSAGE(QString("ClaudeMessage: Creating redacted_thinking block with signature length=%1")
.arg(signature.length()));
addCurrentContent<PluginLLMCore::RedactedThinkingContent>(signature);
}
}
void ClaudeMessage::handleContentBlockDelta(
int index, const QString &deltaType, const QJsonObject &delta)
{
if (index >= m_currentBlocks.size()) {
return;
}
if (deltaType == "text_delta") {
if (auto textContent = qobject_cast<PluginLLMCore::TextContent *>(m_currentBlocks[index])) {
textContent->appendText(delta["text"].toString());
}
} else if (deltaType == "input_json_delta") {
QString partialJson = delta["partial_json"].toString();
if (m_pendingToolInputs.contains(index)) {
m_pendingToolInputs[index] += partialJson;
}
} else if (deltaType == "thinking_delta") {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(m_currentBlocks[index])) {
thinkingContent->appendThinking(delta["thinking"].toString());
}
} else if (deltaType == "signature_delta") {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(m_currentBlocks[index])) {
QString signature = delta["signature"].toString();
thinkingContent->setSignature(signature);
LOG_MESSAGE(QString("Set signature for thinking block %1: length=%2")
.arg(index).arg(signature.length()));
} else if (auto redactedContent = qobject_cast<PluginLLMCore::RedactedThinkingContent *>(m_currentBlocks[index])) {
QString signature = delta["signature"].toString();
redactedContent->setSignature(signature);
LOG_MESSAGE(QString("Set signature for redacted_thinking block %1: length=%2")
.arg(index).arg(signature.length()));
}
}
}
void ClaudeMessage::handleContentBlockStop(int index)
{
if (m_pendingToolInputs.contains(index)) {
QString jsonInput = m_pendingToolInputs[index];
QJsonObject inputObject;
if (!jsonInput.isEmpty()) {
QJsonDocument doc = QJsonDocument::fromJson(jsonInput.toUtf8());
if (doc.isObject()) {
inputObject = doc.object();
}
}
if (index < m_currentBlocks.size()) {
if (auto toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(m_currentBlocks[index])) {
toolContent->setInput(inputObject);
}
}
m_pendingToolInputs.remove(index);
}
}
void ClaudeMessage::handleStopReason(const QString &stopReason)
{
m_stopReason = stopReason;
updateStateFromStopReason();
}
QJsonObject ClaudeMessage::toProviderFormat() const
{
QJsonObject message;
message["role"] = "assistant";
QJsonArray content;
for (auto block : m_currentBlocks) {
QJsonValue blockJson = block->toJson(PluginLLMCore::ProviderFormat::Claude);
content.append(blockJson);
}
message["content"] = content;
LOG_MESSAGE(QString("ClaudeMessage::toProviderFormat - message with %1 content block(s)")
.arg(m_currentBlocks.size()));
return message;
}
QJsonArray ClaudeMessage::createToolResultsContent(const QHash<QString, QString> &toolResults) const
{
QJsonArray results;
for (auto toolContent : getCurrentToolUseContent()) {
if (toolResults.contains(toolContent->id())) {
auto toolResult = std::make_unique<PluginLLMCore::ToolResultContent>(
toolContent->id(), toolResults[toolContent->id()]);
results.append(toolResult->toJson(PluginLLMCore::ProviderFormat::Claude));
}
}
return results;
}
QList<PluginLLMCore::ToolUseContent *> ClaudeMessage::getCurrentToolUseContent() const
{
QList<PluginLLMCore::ToolUseContent *> toolBlocks;
for (auto block : m_currentBlocks) {
if (auto toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
toolBlocks.append(toolContent);
}
}
return toolBlocks;
}
QList<PluginLLMCore::ThinkingContent *> ClaudeMessage::getCurrentThinkingContent() const
{
QList<PluginLLMCore::ThinkingContent *> thinkingBlocks;
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
thinkingBlocks.append(thinkingContent);
}
}
return thinkingBlocks;
}
QList<PluginLLMCore::RedactedThinkingContent *> ClaudeMessage::getCurrentRedactedThinkingContent() const
{
QList<PluginLLMCore::RedactedThinkingContent *> redactedBlocks;
for (auto block : m_currentBlocks) {
if (auto redactedContent = qobject_cast<PluginLLMCore::RedactedThinkingContent *>(block)) {
redactedBlocks.append(redactedContent);
}
}
return redactedBlocks;
}
void ClaudeMessage::startNewContinuation()
{
LOG_MESSAGE(QString("ClaudeMessage: Starting new continuation"));
m_currentBlocks.clear();
m_pendingToolInputs.clear();
m_stopReason.clear();
m_state = PluginLLMCore::MessageState::Building;
}
void ClaudeMessage::updateStateFromStopReason()
{
if (m_stopReason == "tool_use" && !getCurrentToolUseContent().empty()) {
m_state = PluginLLMCore::MessageState::RequiresToolExecution;
} else if (m_stopReason == "end_turn") {
m_state = PluginLLMCore::MessageState::Final;
} else {
m_state = PluginLLMCore::MessageState::Complete;
}
}
} // namespace QodeAssist

View File

@@ -1,50 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <pluginllmcore/ContentBlocks.hpp>
namespace QodeAssist {
class ClaudeMessage : public QObject
{
Q_OBJECT
public:
explicit ClaudeMessage(QObject *parent = nullptr);
void handleContentBlockStart(int index, const QString &blockType, const QJsonObject &data);
void handleContentBlockDelta(int index, const QString &deltaType, const QJsonObject &delta);
void handleContentBlockStop(int index);
void handleStopReason(const QString &stopReason);
QJsonObject toProviderFormat() const;
QJsonArray createToolResultsContent(const QHash<QString, QString> &toolResults) const;
PluginLLMCore::MessageState state() const { return m_state; }
QList<PluginLLMCore::ToolUseContent *> getCurrentToolUseContent() const;
QList<PluginLLMCore::ThinkingContent *> getCurrentThinkingContent() const;
QList<PluginLLMCore::RedactedThinkingContent *> getCurrentRedactedThinkingContent() const;
const QList<PluginLLMCore::ContentBlock *> &getCurrentBlocks() const { return m_currentBlocks; }
void startNewContinuation();
private:
QString m_stopReason;
PluginLLMCore::MessageState m_state = PluginLLMCore::MessageState::Building;
QList<PluginLLMCore::ContentBlock *> m_currentBlocks;
QHash<int, QString> m_pendingToolInputs;
void updateStateFromStopReason();
template<typename T, typename... Args>
T *addCurrentContent(Args &&...args)
{
T *content = new T(std::forward<Args>(args)...);
content->setParent(this);
m_currentBlocks.append(content);
return content;
}
};
} // namespace QodeAssist

View File

@@ -9,6 +9,7 @@
#include <LLMQore/ToolsManager.hpp>
#include "ClaudeCacheControl.hpp"
#include "logger/Logger.hpp"
#include "settings/ChatAssistantSettings.hpp"
#include "settings/CodeCompletionSettings.hpp"
@@ -104,6 +105,14 @@ void ClaudeProvider::prepareRequest(
LOG_MESSAGE(QString("Added %1 tools to Claude request").arg(toolsDefinitions.size()));
}
}
const auto &ps = Settings::providerSettings();
const bool cachingOn = ps.claudeEnablePromptCaching()
&& type != PluginLLMCore::RequestType::CodeCompletion;
m_client->setUseExtendedCacheTTL(cachingOn && ps.claudeUseExtendedCacheTTL());
if (cachingOn) {
ClaudeCacheControl::apply(request, ps.claudeUseExtendedCacheTTL());
}
}
QFuture<QList<QString>> ClaudeProvider::getInstalledModels(const QString &baseUrl)

View File

@@ -1,228 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "GoogleMessage.hpp"
#include <QJsonDocument>
#include <QUuid>
#include "logger/Logger.hpp"
namespace QodeAssist::Providers {
GoogleMessage::GoogleMessage(QObject *parent)
: QObject(parent)
{}
void GoogleMessage::handleContentDelta(const QString &text)
{
if (m_currentBlocks.isEmpty() || !qobject_cast<PluginLLMCore::TextContent *>(m_currentBlocks.last())) {
auto textContent = new PluginLLMCore::TextContent();
textContent->setParent(this);
m_currentBlocks.append(textContent);
}
if (auto textContent = qobject_cast<PluginLLMCore::TextContent *>(m_currentBlocks.last())) {
textContent->appendText(text);
}
}
void GoogleMessage::handleThoughtDelta(const QString &text)
{
if (m_currentBlocks.isEmpty() || !qobject_cast<PluginLLMCore::ThinkingContent *>(m_currentBlocks.last())) {
auto thinkingContent = new PluginLLMCore::ThinkingContent();
thinkingContent->setParent(this);
m_currentBlocks.append(thinkingContent);
}
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(m_currentBlocks.last())) {
thinkingContent->appendThinking(text);
}
}
void GoogleMessage::handleThoughtSignature(const QString &signature)
{
for (int i = m_currentBlocks.size() - 1; i >= 0; --i) {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(m_currentBlocks[i])) {
thinkingContent->setSignature(signature);
return;
}
}
auto thinkingContent = new PluginLLMCore::ThinkingContent();
thinkingContent->setParent(this);
thinkingContent->setSignature(signature);
m_currentBlocks.append(thinkingContent);
}
void GoogleMessage::handleFunctionCallStart(const QString &name)
{
m_currentFunctionName = name;
m_pendingFunctionArgs.clear();
}
void GoogleMessage::handleFunctionCallArgsDelta(const QString &argsJson)
{
m_pendingFunctionArgs += argsJson;
}
void GoogleMessage::handleFunctionCallComplete()
{
if (m_currentFunctionName.isEmpty()) {
return;
}
QJsonObject args;
if (!m_pendingFunctionArgs.isEmpty()) {
QJsonDocument doc = QJsonDocument::fromJson(m_pendingFunctionArgs.toUtf8());
if (doc.isObject()) {
args = doc.object();
}
}
QString id = QUuid::createUuid().toString(QUuid::WithoutBraces);
auto toolContent = new PluginLLMCore::ToolUseContent(id, m_currentFunctionName, args);
toolContent->setParent(this);
m_currentBlocks.append(toolContent);
m_currentFunctionName.clear();
m_pendingFunctionArgs.clear();
}
void GoogleMessage::handleFinishReason(const QString &reason)
{
m_finishReason = reason;
updateStateFromFinishReason();
}
QJsonObject GoogleMessage::toProviderFormat() const
{
QJsonObject content;
content["role"] = "model";
QJsonArray parts;
for (auto block : m_currentBlocks) {
if (!block)
continue;
if (auto text = qobject_cast<PluginLLMCore::TextContent *>(block)) {
parts.append(QJsonObject{{"text", text->text()}});
} else if (auto tool = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
QJsonObject functionCall;
functionCall["name"] = tool->name();
functionCall["args"] = tool->input();
parts.append(QJsonObject{{"functionCall", functionCall}});
} else if (auto thinking = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
// Include thinking blocks with their text
QJsonObject thinkingPart;
thinkingPart["text"] = thinking->thinking();
thinkingPart["thought"] = true;
parts.append(thinkingPart);
// If there's a signature, add it as a separate part
if (!thinking->signature().isEmpty()) {
QJsonObject signaturePart;
signaturePart["thoughtSignature"] = thinking->signature();
parts.append(signaturePart);
}
}
}
content["parts"] = parts;
return content;
}
QJsonArray GoogleMessage::createToolResultParts(const QHash<QString, QString> &toolResults) const
{
QJsonArray parts;
for (auto toolContent : getCurrentToolUseContent()) {
if (toolResults.contains(toolContent->id())) {
QJsonObject functionResponse;
functionResponse["name"] = toolContent->name();
QJsonObject response;
response["result"] = toolResults[toolContent->id()];
functionResponse["response"] = response;
parts.append(QJsonObject{{"functionResponse", functionResponse}});
}
}
return parts;
}
QList<PluginLLMCore::ToolUseContent *> GoogleMessage::getCurrentToolUseContent() const
{
QList<PluginLLMCore::ToolUseContent *> toolBlocks;
for (auto block : m_currentBlocks) {
if (auto toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
toolBlocks.append(toolContent);
}
}
return toolBlocks;
}
QList<PluginLLMCore::ThinkingContent *> GoogleMessage::getCurrentThinkingContent() const
{
QList<PluginLLMCore::ThinkingContent *> thinkingBlocks;
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
thinkingBlocks.append(thinkingContent);
}
}
return thinkingBlocks;
}
void GoogleMessage::startNewContinuation()
{
LOG_MESSAGE(QString("GoogleMessage: Starting new continuation"));
m_currentBlocks.clear();
m_pendingFunctionArgs.clear();
m_currentFunctionName.clear();
m_finishReason.clear();
m_state = PluginLLMCore::MessageState::Building;
}
bool GoogleMessage::isErrorFinishReason() const
{
return m_finishReason == "SAFETY"
|| m_finishReason == "RECITATION"
|| m_finishReason == "MALFORMED_FUNCTION_CALL"
|| m_finishReason == "PROHIBITED_CONTENT"
|| m_finishReason == "SPII"
|| m_finishReason == "OTHER";
}
QString GoogleMessage::getErrorMessage() const
{
if (m_finishReason == "SAFETY") {
return "Response blocked by safety filters";
} else if (m_finishReason == "RECITATION") {
return "Response blocked due to recitation of copyrighted content";
} else if (m_finishReason == "MALFORMED_FUNCTION_CALL") {
return "Model attempted to call a function with malformed arguments. Please try rephrasing your request or disabling tools.";
} else if (m_finishReason == "PROHIBITED_CONTENT") {
return "Response blocked due to prohibited content";
} else if (m_finishReason == "SPII") {
return "Response blocked due to sensitive personally identifiable information";
} else if (m_finishReason == "OTHER") {
return "Request failed due to an unknown reason";
}
return QString();
}
void GoogleMessage::updateStateFromFinishReason()
{
if (m_finishReason == "STOP" || m_finishReason == "MAX_TOKENS") {
m_state = getCurrentToolUseContent().isEmpty()
? PluginLLMCore::MessageState::Complete
: PluginLLMCore::MessageState::RequiresToolExecution;
} else {
m_state = PluginLLMCore::MessageState::Complete;
}
}
} // namespace QodeAssist::Providers

View File

@@ -1,52 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
#include <pluginllmcore/ContentBlocks.hpp>
namespace QodeAssist::Providers {
class GoogleMessage : public QObject
{
Q_OBJECT
public:
explicit GoogleMessage(QObject *parent = nullptr);
void handleContentDelta(const QString &text);
void handleThoughtDelta(const QString &text);
void handleThoughtSignature(const QString &signature);
void handleFunctionCallStart(const QString &name);
void handleFunctionCallArgsDelta(const QString &argsJson);
void handleFunctionCallComplete();
void handleFinishReason(const QString &reason);
QJsonObject toProviderFormat() const;
QJsonArray createToolResultParts(const QHash<QString, QString> &toolResults) const;
QList<PluginLLMCore::ToolUseContent *> getCurrentToolUseContent() const;
QList<PluginLLMCore::ThinkingContent *> getCurrentThinkingContent() const;
QList<PluginLLMCore::ContentBlock *> currentBlocks() const { return m_currentBlocks; }
PluginLLMCore::MessageState state() const { return m_state; }
QString finishReason() const { return m_finishReason; }
bool isErrorFinishReason() const;
QString getErrorMessage() const;
void startNewContinuation();
private:
void updateStateFromFinishReason();
QList<PluginLLMCore::ContentBlock *> m_currentBlocks;
QString m_pendingFunctionArgs;
QString m_currentFunctionName;
QString m_finishReason;
PluginLLMCore::MessageState m_state = PluginLLMCore::MessageState::Building;
};
} // namespace QodeAssist::Providers

View File

@@ -5,6 +5,7 @@
#include <LLMQore/ToolsManager.hpp>
#include "providers/ProviderUrlUtils.hpp"
#include "tools/ToolsRegistration.hpp"
#include "logger/Logger.hpp"
#include "settings/ChatAssistantSettings.hpp"
@@ -38,12 +39,12 @@ QString LMStudioProvider::apiKey() const
QString LMStudioProvider::url() const
{
return "http://localhost:1234/v1";
return "http://localhost:1234";
}
QFuture<QList<QString>> LMStudioProvider::getInstalledModels(const QString &url)
{
m_client->setUrl(url);
m_client->setUrl(ensureOpenAIV1Base(url));
m_client->setApiKey(apiKey());
return m_client->listModels();
}
@@ -104,6 +105,13 @@ void LMStudioProvider::prepareRequest(
}
}
PluginLLMCore::RequestID LMStudioProvider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
return PluginLLMCore::Provider::sendRequest(
QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint);
}
::LLMQore::BaseClient *LMStudioProvider::client() const
{
return m_client;

View File

@@ -30,6 +30,9 @@ public:
::LLMQore::BaseClient *client() const override;
QString apiKey() const override;
PluginLLMCore::RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
private:
::LLMQore::OpenAIClient *m_client;
};

View File

@@ -6,6 +6,7 @@
#include <LLMQore/ToolsManager.hpp>
#include "logger/Logger.hpp"
#include "providers/ProviderUrlUtils.hpp"
#include "settings/ChatAssistantSettings.hpp"
#include "settings/CodeCompletionSettings.hpp"
#include "settings/GeneralSettings.hpp"
@@ -112,10 +113,7 @@ void LMStudioResponsesProvider::prepareRequest(
QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QString &baseUrl)
{
QString url = baseUrl;
if (!url.endsWith(QStringLiteral("/v1")))
url += QStringLiteral("/v1");
m_client->setUrl(url);
m_client->setUrl(ensureOpenAIV1Base(baseUrl));
m_client->setApiKey(apiKey());
return m_client->listModels();
}
@@ -123,9 +121,8 @@ QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QStr
PluginLLMCore::RequestID LMStudioResponsesProvider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
const QString effectiveEndpoint
= endpoint.isEmpty() ? QStringLiteral("/v1/responses") : endpoint;
return PluginLLMCore::Provider::sendRequest(url, payload, effectiveEndpoint);
return PluginLLMCore::Provider::sendRequest(
QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint);
}
PluginLLMCore::ProviderID LMStudioResponsesProvider::providerID() const

View File

@@ -68,12 +68,26 @@ void LlamaCppProvider::prepareRequest(
request["presence_penalty"] = settings.presencePenalty();
};
auto applyThinkingMode = [&request]() {
QJsonObject chatTemplateKwargs = request["chat_template_kwargs"].toObject();
chatTemplateKwargs["enable_thinking"] = true;
request["chat_template_kwargs"] = chatTemplateKwargs;
};
if (type == PluginLLMCore::RequestType::CodeCompletion) {
applyModelParams(Settings::codeCompletionSettings());
} else if (type == PluginLLMCore::RequestType::QuickRefactoring) {
applyModelParams(Settings::quickRefactorSettings());
if (isThinkingEnabled) {
applyThinkingMode();
LOG_MESSAGE(QString("LlamaCppProvider: Thinking mode enabled for QuickRefactoring"));
}
} else {
applyModelParams(Settings::chatAssistantSettings());
if (isThinkingEnabled) {
applyThinkingMode();
LOG_MESSAGE(QString("LlamaCppProvider: Thinking mode enabled for Chat"));
}
}
if (isToolsEnabled) {
@@ -85,9 +99,11 @@ void LlamaCppProvider::prepareRequest(
}
}
QFuture<QList<QString>> LlamaCppProvider::getInstalledModels(const QString &)
QFuture<QList<QString>> LlamaCppProvider::getInstalledModels(const QString &baseUrl)
{
return QtFuture::makeReadyFuture(QList<QString>{});
m_client->setUrl(baseUrl);
m_client->setApiKey(Settings::providerSettings().llamaCppApiKey());
return m_client->listModels();
}
PluginLLMCore::ProviderID LlamaCppProvider::providerID() const
@@ -97,7 +113,9 @@ PluginLLMCore::ProviderID LlamaCppProvider::providerID() const
PluginLLMCore::ProviderCapabilities LlamaCppProvider::capabilities() const
{
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image;
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking
| PluginLLMCore::ProviderCapability::Image
| PluginLLMCore::ProviderCapability::ModelListing;
}
::LLMQore::BaseClient *LlamaCppProvider::client() const

View File

@@ -1,349 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "OllamaMessage.hpp"
#include "logger/Logger.hpp"
#include <QJsonArray>
#include <QJsonDocument>
namespace QodeAssist::Providers {
OllamaMessage::OllamaMessage(QObject *parent)
: QObject(parent)
{}
void OllamaMessage::handleContentDelta(const QString &content)
{
m_accumulatedContent += content;
QString trimmed = m_accumulatedContent.trimmed();
if (trimmed.startsWith('{')) {
return;
}
if (!m_contentAddedToTextBlock) {
PluginLLMCore::TextContent *textContent = getOrCreateTextContent();
textContent->setText(m_accumulatedContent);
m_contentAddedToTextBlock = true;
LOG_MESSAGE(QString("OllamaMessage: Added accumulated content to TextContent, length=%1")
.arg(m_accumulatedContent.length()));
} else {
PluginLLMCore::TextContent *textContent = getOrCreateTextContent();
textContent->appendText(content);
}
}
void OllamaMessage::handleToolCall(const QJsonObject &toolCall)
{
QJsonObject function = toolCall["function"].toObject();
QString name = function["name"].toString();
QJsonObject arguments = function["arguments"].toObject();
QString toolId = QString("call_%1_%2").arg(name).arg(QDateTime::currentMSecsSinceEpoch());
if (!m_contentAddedToTextBlock && !m_accumulatedContent.trimmed().isEmpty()) {
LOG_MESSAGE(
QString("OllamaMessage: Clearing accumulated content (tool call detected), length=%1")
.arg(m_accumulatedContent.length()));
m_accumulatedContent.clear();
}
addCurrentContent<PluginLLMCore::ToolUseContent>(toolId, name, arguments);
LOG_MESSAGE(
QString("OllamaMessage: Structured tool call detected - name=%1, id=%2").arg(name, toolId));
}
void OllamaMessage::handleThinkingDelta(const QString &thinking)
{
PluginLLMCore::ThinkingContent *thinkingContent = getOrCreateThinkingContent();
thinkingContent->appendThinking(thinking);
}
void OllamaMessage::handleThinkingComplete(const QString &signature)
{
if (m_currentThinkingContent) {
m_currentThinkingContent->setSignature(signature);
LOG_MESSAGE(QString("OllamaMessage: Set thinking signature, length=%1")
.arg(signature.length()));
}
}
void OllamaMessage::handleDone(bool done)
{
m_done = done;
if (done) {
bool isToolCall = tryParseToolCall();
if (!isToolCall && !m_contentAddedToTextBlock && !m_accumulatedContent.trimmed().isEmpty()) {
QString trimmed = m_accumulatedContent.trimmed();
if (trimmed.startsWith('{')
&& (trimmed.contains("\"name\"") || trimmed.contains("\"arguments\""))) {
LOG_MESSAGE(
QString("OllamaMessage: Skipping invalid/incomplete tool call JSON (length=%1)")
.arg(trimmed.length()));
for (auto it = m_currentBlocks.begin(); it != m_currentBlocks.end();) {
if (qobject_cast<PluginLLMCore::TextContent *>(*it)) {
LOG_MESSAGE(QString(
"OllamaMessage: Removing TextContent block (incomplete tool call)"));
(*it)->deleteLater();
it = m_currentBlocks.erase(it);
} else {
++it;
}
}
m_accumulatedContent.clear();
} else {
PluginLLMCore::TextContent *textContent = getOrCreateTextContent();
textContent->setText(m_accumulatedContent);
m_contentAddedToTextBlock = true;
LOG_MESSAGE(
QString(
"OllamaMessage: Added final accumulated content to TextContent, length=%1")
.arg(m_accumulatedContent.length()));
}
}
updateStateFromDone();
}
}
bool OllamaMessage::tryParseToolCall()
{
QString trimmed = m_accumulatedContent.trimmed();
if (trimmed.isEmpty()) {
return false;
}
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(trimmed.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
LOG_MESSAGE(QString("OllamaMessage: Content is not valid JSON (not a tool call): %1")
.arg(parseError.errorString()));
return false;
}
if (!doc.isObject()) {
LOG_MESSAGE(QString("OllamaMessage: Content is not a JSON object (not a tool call)"));
return false;
}
QJsonObject obj = doc.object();
if (!obj.contains("name") || !obj.contains("arguments")) {
LOG_MESSAGE(
QString("OllamaMessage: JSON missing 'name' or 'arguments' fields (not a tool call)"));
return false;
}
QString name = obj["name"].toString();
QJsonValue argsValue = obj["arguments"];
QJsonObject arguments;
if (argsValue.isObject()) {
arguments = argsValue.toObject();
} else if (argsValue.isString()) {
QJsonDocument argsDoc = QJsonDocument::fromJson(argsValue.toString().toUtf8());
if (argsDoc.isObject()) {
arguments = argsDoc.object();
} else {
LOG_MESSAGE(QString("OllamaMessage: Failed to parse arguments as JSON object"));
return false;
}
} else {
LOG_MESSAGE(QString("OllamaMessage: Arguments field is neither object nor string"));
return false;
}
if (name.isEmpty()) {
LOG_MESSAGE(QString("OllamaMessage: Tool name is empty"));
return false;
}
QString toolId = QString("call_%1_%2").arg(name).arg(QDateTime::currentMSecsSinceEpoch());
for (auto block : m_currentBlocks) {
if (qobject_cast<PluginLLMCore::TextContent *>(block)) {
LOG_MESSAGE(QString("OllamaMessage: Removing TextContent block (tool call detected)"));
}
}
m_currentBlocks.clear();
addCurrentContent<PluginLLMCore::ToolUseContent>(toolId, name, arguments);
LOG_MESSAGE(
QString(
"OllamaMessage: Successfully parsed tool call from legacy format - name=%1, id=%2, "
"args=%3")
.arg(
name,
toolId,
QString::fromUtf8(QJsonDocument(arguments).toJson(QJsonDocument::Compact))));
return true;
}
bool OllamaMessage::isLikelyToolCallJson(const QString &content) const
{
QString trimmed = content.trimmed();
if (trimmed.startsWith('{')) {
if (trimmed.contains("\"name\"") && trimmed.contains("\"arguments\"")) {
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(trimmed.toUtf8(), &parseError);
if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
QJsonObject obj = doc.object();
if (obj.contains("name") && obj.contains("arguments")) {
return true;
}
}
}
}
return false;
}
QJsonObject OllamaMessage::toProviderFormat() const
{
QJsonObject message;
message["role"] = "assistant";
QString textContent;
QJsonArray toolCalls;
QString thinkingContent;
for (auto block : m_currentBlocks) {
if (!block)
continue;
if (auto text = qobject_cast<PluginLLMCore::TextContent *>(block)) {
textContent += text->text();
} else if (auto tool = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
QJsonObject toolCall;
toolCall["type"] = "function";
toolCall["function"] = QJsonObject{{"name", tool->name()}, {"arguments", tool->input()}};
toolCalls.append(toolCall);
} else if (auto thinking = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
thinkingContent += thinking->thinking();
}
}
if (!thinkingContent.isEmpty()) {
message["thinking"] = thinkingContent;
}
if (!textContent.isEmpty()) {
message["content"] = textContent;
}
if (!toolCalls.isEmpty()) {
message["tool_calls"] = toolCalls;
}
return message;
}
QJsonArray OllamaMessage::createToolResultMessages(const QHash<QString, QString> &toolResults) const
{
QJsonArray messages;
for (auto toolContent : getCurrentToolUseContent()) {
if (toolResults.contains(toolContent->id())) {
QJsonObject toolMessage;
toolMessage["role"] = "tool";
toolMessage["content"] = toolResults[toolContent->id()];
messages.append(toolMessage);
LOG_MESSAGE(QString(
"OllamaMessage: Created tool result message for tool %1 (id=%2), "
"content length=%3")
.arg(toolContent->name(), toolContent->id())
.arg(toolResults[toolContent->id()].length()));
}
}
return messages;
}
QList<PluginLLMCore::ToolUseContent *> OllamaMessage::getCurrentToolUseContent() const
{
QList<PluginLLMCore::ToolUseContent *> toolBlocks;
for (auto block : m_currentBlocks) {
if (auto toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
toolBlocks.append(toolContent);
}
}
return toolBlocks;
}
QList<PluginLLMCore::ThinkingContent *> OllamaMessage::getCurrentThinkingContent() const
{
QList<PluginLLMCore::ThinkingContent *> thinkingBlocks;
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
thinkingBlocks.append(thinkingContent);
}
}
return thinkingBlocks;
}
void OllamaMessage::startNewContinuation()
{
LOG_MESSAGE(QString("OllamaMessage: Starting new continuation"));
m_currentBlocks.clear();
m_accumulatedContent.clear();
m_done = false;
m_state = PluginLLMCore::MessageState::Building;
m_contentAddedToTextBlock = false;
m_currentThinkingContent = nullptr;
}
void OllamaMessage::updateStateFromDone()
{
if (!getCurrentToolUseContent().empty()) {
m_state = PluginLLMCore::MessageState::RequiresToolExecution;
LOG_MESSAGE(QString("OllamaMessage: State set to RequiresToolExecution, tools count=%1")
.arg(getCurrentToolUseContent().size()));
} else {
m_state = PluginLLMCore::MessageState::Final;
LOG_MESSAGE(QString("OllamaMessage: State set to Final"));
}
}
PluginLLMCore::TextContent *OllamaMessage::getOrCreateTextContent()
{
for (auto block : m_currentBlocks) {
if (auto textContent = qobject_cast<PluginLLMCore::TextContent *>(block)) {
return textContent;
}
}
return addCurrentContent<PluginLLMCore::TextContent>();
}
PluginLLMCore::ThinkingContent *OllamaMessage::getOrCreateThinkingContent()
{
if (m_currentThinkingContent) {
return m_currentThinkingContent;
}
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
m_currentThinkingContent = thinkingContent;
return m_currentThinkingContent;
}
}
m_currentThinkingContent = addCurrentContent<PluginLLMCore::ThinkingContent>();
LOG_MESSAGE(QString("OllamaMessage: Created new ThinkingContent block"));
return m_currentThinkingContent;
}
} // namespace QodeAssist::Providers

View File

@@ -1,56 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <pluginllmcore/ContentBlocks.hpp>
namespace QodeAssist::Providers {
class OllamaMessage : public QObject
{
Q_OBJECT
public:
explicit OllamaMessage(QObject *parent = nullptr);
void handleContentDelta(const QString &content);
void handleToolCall(const QJsonObject &toolCall);
void handleThinkingDelta(const QString &thinking);
void handleThinkingComplete(const QString &signature);
void handleDone(bool done);
QJsonObject toProviderFormat() const;
QJsonArray createToolResultMessages(const QHash<QString, QString> &toolResults) const;
PluginLLMCore::MessageState state() const { return m_state; }
QList<PluginLLMCore::ToolUseContent *> getCurrentToolUseContent() const;
QList<PluginLLMCore::ThinkingContent *> getCurrentThinkingContent() const;
QList<PluginLLMCore::ContentBlock *> currentBlocks() const { return m_currentBlocks; }
void startNewContinuation();
private:
bool m_done = false;
PluginLLMCore::MessageState m_state = PluginLLMCore::MessageState::Building;
QList<PluginLLMCore::ContentBlock *> m_currentBlocks;
QString m_accumulatedContent;
bool m_contentAddedToTextBlock = false;
PluginLLMCore::ThinkingContent *m_currentThinkingContent = nullptr;
void updateStateFromDone();
bool tryParseToolCall();
bool isLikelyToolCallJson(const QString &content) const;
PluginLLMCore::TextContent *getOrCreateTextContent();
PluginLLMCore::ThinkingContent *getOrCreateThinkingContent();
template<typename T, typename... Args>
T *addCurrentContent(Args &&...args)
{
T *content = new T(std::forward<Args>(args)...);
content->setParent(this);
m_currentBlocks.append(content);
return content;
}
};
} // namespace QodeAssist::Providers

View File

@@ -1,165 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "OpenAIMessage.hpp"
#include "logger/Logger.hpp"
#include <QJsonArray>
#include <QJsonDocument>
namespace QodeAssist::Providers {
OpenAIMessage::OpenAIMessage(QObject *parent)
: QObject(parent)
{}
void OpenAIMessage::handleContentDelta(const QString &content)
{
auto textContent = getOrCreateTextContent();
textContent->appendText(content);
}
void OpenAIMessage::handleToolCallStart(int index, const QString &id, const QString &name)
{
LOG_MESSAGE(QString("OpenAIMessage: handleToolCallStart index=%1, id=%2, name=%3")
.arg(index)
.arg(id, name));
while (m_currentBlocks.size() <= index) {
m_currentBlocks.append(nullptr);
}
auto toolContent = new PluginLLMCore::ToolUseContent(id, name);
toolContent->setParent(this);
m_currentBlocks[index] = toolContent;
m_pendingToolArguments[index] = "";
}
void OpenAIMessage::handleToolCallDelta(int index, const QString &argumentsDelta)
{
if (m_pendingToolArguments.contains(index)) {
m_pendingToolArguments[index] += argumentsDelta;
}
}
void OpenAIMessage::handleToolCallComplete(int index)
{
if (m_pendingToolArguments.contains(index)) {
QString jsonArgs = m_pendingToolArguments[index];
QJsonObject argsObject;
if (!jsonArgs.isEmpty()) {
QJsonDocument doc = QJsonDocument::fromJson(jsonArgs.toUtf8());
if (doc.isObject()) {
argsObject = doc.object();
}
}
if (index < m_currentBlocks.size()) {
if (auto toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(m_currentBlocks[index])) {
toolContent->setInput(argsObject);
}
}
m_pendingToolArguments.remove(index);
}
}
void OpenAIMessage::handleFinishReason(const QString &finishReason)
{
m_finishReason = finishReason;
updateStateFromFinishReason();
}
QJsonObject OpenAIMessage::toProviderFormat() const
{
QJsonObject message;
message["role"] = "assistant";
QString textContent;
QJsonArray toolCalls;
for (auto block : m_currentBlocks) {
if (!block)
continue;
if (auto text = qobject_cast<PluginLLMCore::TextContent *>(block)) {
textContent += text->text();
} else if (auto tool = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
toolCalls.append(tool->toJson(PluginLLMCore::ProviderFormat::OpenAI));
}
}
if (!textContent.isEmpty()) {
message["content"] = textContent;
} else {
message["content"] = QJsonValue();
}
if (!toolCalls.isEmpty()) {
message["tool_calls"] = toolCalls;
}
return message;
}
QJsonArray OpenAIMessage::createToolResultMessages(const QHash<QString, QString> &toolResults) const
{
QJsonArray messages;
for (auto toolContent : getCurrentToolUseContent()) {
if (toolResults.contains(toolContent->id())) {
auto toolResult = std::make_unique<PluginLLMCore::ToolResultContent>(
toolContent->id(), toolResults[toolContent->id()]);
messages.append(toolResult->toJson(PluginLLMCore::ProviderFormat::OpenAI));
}
}
return messages;
}
QList<PluginLLMCore::ToolUseContent *> OpenAIMessage::getCurrentToolUseContent() const
{
QList<PluginLLMCore::ToolUseContent *> toolBlocks;
for (auto block : m_currentBlocks) {
if (auto toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
toolBlocks.append(toolContent);
}
}
return toolBlocks;
}
void OpenAIMessage::startNewContinuation()
{
LOG_MESSAGE(QString("OpenAIAPIMessage: Starting new continuation"));
m_currentBlocks.clear();
m_pendingToolArguments.clear();
m_finishReason.clear();
m_state = PluginLLMCore::MessageState::Building;
}
void OpenAIMessage::updateStateFromFinishReason()
{
if (m_finishReason == "tool_calls" && !getCurrentToolUseContent().empty()) {
m_state = PluginLLMCore::MessageState::RequiresToolExecution;
} else if (m_finishReason == "stop") {
m_state = PluginLLMCore::MessageState::Final;
} else {
m_state = PluginLLMCore::MessageState::Complete;
}
}
PluginLLMCore::TextContent *OpenAIMessage::getOrCreateTextContent()
{
for (auto block : m_currentBlocks) {
if (auto textContent = qobject_cast<PluginLLMCore::TextContent *>(block)) {
return textContent;
}
}
return addCurrentContent<PluginLLMCore::TextContent>();
}
} // namespace QodeAssist::Providers

View File

@@ -1,49 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <pluginllmcore/ContentBlocks.hpp>
namespace QodeAssist::Providers {
class OpenAIMessage : public QObject
{
Q_OBJECT
public:
explicit OpenAIMessage(QObject *parent = nullptr);
void handleContentDelta(const QString &content);
void handleToolCallStart(int index, const QString &id, const QString &name);
void handleToolCallDelta(int index, const QString &argumentsDelta);
void handleToolCallComplete(int index);
void handleFinishReason(const QString &finishReason);
QJsonObject toProviderFormat() const;
QJsonArray createToolResultMessages(const QHash<QString, QString> &toolResults) const;
PluginLLMCore::MessageState state() const { return m_state; }
QList<PluginLLMCore::ToolUseContent *> getCurrentToolUseContent() const;
void startNewContinuation();
private:
QString m_finishReason;
PluginLLMCore::MessageState m_state = PluginLLMCore::MessageState::Building;
QList<PluginLLMCore::ContentBlock *> m_currentBlocks;
QHash<int, QString> m_pendingToolArguments;
void updateStateFromFinishReason();
PluginLLMCore::TextContent *getOrCreateTextContent();
template<typename T, typename... Args>
T *addCurrentContent(Args &&...args)
{
T *content = new T(std::forward<Args>(args)...);
content->setParent(this);
m_currentBlocks.append(content);
return content;
}
};
} // namespace QodeAssist::Providers

View File

@@ -1,38 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
namespace QodeAssist::OpenAIResponses {
struct CancelResponseRequest
{
QString responseId;
QString buildUrl(const QString &baseUrl) const
{
return QString("%1/v1/responses/%2/cancel").arg(baseUrl, responseId);
}
bool isValid() const { return !responseId.isEmpty(); }
};
class CancelResponseRequestBuilder
{
public:
CancelResponseRequestBuilder &setResponseId(const QString &id)
{
m_request.responseId = id;
return *this;
}
CancelResponseRequest build() const { return m_request; }
private:
CancelResponseRequest m_request;
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,53 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonObject>
#include <QString>
namespace QodeAssist::OpenAIResponses {
struct DeleteResponseRequest
{
QString responseId;
QString buildUrl(const QString &baseUrl) const
{
return QString("%1/v1/responses/%2").arg(baseUrl, responseId);
}
bool isValid() const { return !responseId.isEmpty(); }
};
class DeleteResponseRequestBuilder
{
public:
DeleteResponseRequestBuilder &setResponseId(const QString &id)
{
m_request.responseId = id;
return *this;
}
DeleteResponseRequest build() const { return m_request; }
private:
DeleteResponseRequest m_request;
};
struct DeleteResponseResult
{
bool success = false;
QString message;
static DeleteResponseResult fromJson(const QJsonObject &obj)
{
DeleteResponseResult result;
result.success = obj["success"].toBool();
result.message = obj["message"].toString();
return result;
}
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,104 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonObject>
#include <QString>
#include <QStringList>
#include <optional>
namespace QodeAssist::OpenAIResponses {
struct GetResponseRequest
{
QString responseId;
std::optional<QStringList> include;
std::optional<bool> includeObfuscation;
std::optional<int> startingAfter;
std::optional<bool> stream;
QString buildUrl(const QString &baseUrl) const
{
QString url = QString("%1/v1/responses/%2").arg(baseUrl, responseId);
QStringList queryParams;
if (include && !include->isEmpty()) {
for (const auto &item : *include) {
queryParams.append(QString("include=%1").arg(item));
}
}
if (includeObfuscation) {
queryParams.append(
QString("include_obfuscation=%1").arg(*includeObfuscation ? "true" : "false"));
}
if (startingAfter) {
queryParams.append(QString("starting_after=%1").arg(*startingAfter));
}
if (stream) {
queryParams.append(QString("stream=%1").arg(*stream ? "true" : "false"));
}
if (!queryParams.isEmpty()) {
url += "?" + queryParams.join("&");
}
return url;
}
bool isValid() const { return !responseId.isEmpty(); }
};
class GetResponseRequestBuilder
{
public:
GetResponseRequestBuilder &setResponseId(const QString &id)
{
m_request.responseId = id;
return *this;
}
GetResponseRequestBuilder &setInclude(const QStringList &include)
{
m_request.include = include;
return *this;
}
GetResponseRequestBuilder &addInclude(const QString &item)
{
if (!m_request.include) {
m_request.include = QStringList();
}
m_request.include->append(item);
return *this;
}
GetResponseRequestBuilder &setIncludeObfuscation(bool enabled)
{
m_request.includeObfuscation = enabled;
return *this;
}
GetResponseRequestBuilder &setStartingAfter(int sequence)
{
m_request.startingAfter = sequence;
return *this;
}
GetResponseRequestBuilder &setStream(bool enabled)
{
m_request.stream = enabled;
return *this;
}
GetResponseRequest build() const { return m_request; }
private:
GetResponseRequest m_request;
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,203 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "ModelRequest.hpp"
#include <QJsonObject>
#include <QString>
namespace QodeAssist::OpenAIResponses {
struct InputTokensRequest
{
std::optional<QString> conversation;
std::optional<QJsonArray> input;
std::optional<QString> instructions;
std::optional<QString> model;
std::optional<bool> parallelToolCalls;
std::optional<QString> previousResponseId;
std::optional<QJsonObject> reasoning;
std::optional<QJsonObject> text;
std::optional<QJsonValue> toolChoice;
std::optional<QJsonArray> tools;
std::optional<QString> truncation;
QString buildUrl(const QString &baseUrl) const
{
return QString("%1/v1/responses/input_tokens").arg(baseUrl);
}
QJsonObject toJson() const
{
QJsonObject obj;
if (conversation)
obj["conversation"] = *conversation;
if (input)
obj["input"] = *input;
if (instructions)
obj["instructions"] = *instructions;
if (model)
obj["model"] = *model;
if (parallelToolCalls)
obj["parallel_tool_calls"] = *parallelToolCalls;
if (previousResponseId)
obj["previous_response_id"] = *previousResponseId;
if (reasoning)
obj["reasoning"] = *reasoning;
if (text)
obj["text"] = *text;
if (toolChoice)
obj["tool_choice"] = *toolChoice;
if (tools)
obj["tools"] = *tools;
if (truncation)
obj["truncation"] = *truncation;
return obj;
}
bool isValid() const { return input.has_value() || previousResponseId.has_value(); }
};
class InputTokensRequestBuilder
{
public:
InputTokensRequestBuilder &setConversation(const QString &conversationId)
{
m_request.conversation = conversationId;
return *this;
}
InputTokensRequestBuilder &setInput(const QJsonArray &input)
{
m_request.input = input;
return *this;
}
InputTokensRequestBuilder &addInputMessage(const Message &message)
{
if (!m_request.input) {
m_request.input = QJsonArray();
}
m_request.input->append(message.toJson());
return *this;
}
InputTokensRequestBuilder &setInstructions(const QString &instructions)
{
m_request.instructions = instructions;
return *this;
}
InputTokensRequestBuilder &setModel(const QString &model)
{
m_request.model = model;
return *this;
}
InputTokensRequestBuilder &setParallelToolCalls(bool enabled)
{
m_request.parallelToolCalls = enabled;
return *this;
}
InputTokensRequestBuilder &setPreviousResponseId(const QString &responseId)
{
m_request.previousResponseId = responseId;
return *this;
}
InputTokensRequestBuilder &setReasoning(const QJsonObject &reasoning)
{
m_request.reasoning = reasoning;
return *this;
}
InputTokensRequestBuilder &setReasoningEffort(ReasoningEffort effort)
{
QString effortStr;
switch (effort) {
case ReasoningEffort::None:
effortStr = "none";
break;
case ReasoningEffort::Minimal:
effortStr = "minimal";
break;
case ReasoningEffort::Low:
effortStr = "low";
break;
case ReasoningEffort::Medium:
effortStr = "medium";
break;
case ReasoningEffort::High:
effortStr = "high";
break;
}
m_request.reasoning = QJsonObject{{"effort", effortStr}};
return *this;
}
InputTokensRequestBuilder &setText(const QJsonObject &text)
{
m_request.text = text;
return *this;
}
InputTokensRequestBuilder &setTextFormat(const TextFormatOptions &format)
{
m_request.text = format.toJson();
return *this;
}
InputTokensRequestBuilder &setToolChoice(const QJsonValue &toolChoice)
{
m_request.toolChoice = toolChoice;
return *this;
}
InputTokensRequestBuilder &setTools(const QJsonArray &tools)
{
m_request.tools = tools;
return *this;
}
InputTokensRequestBuilder &addTool(const Tool &tool)
{
if (!m_request.tools) {
m_request.tools = QJsonArray();
}
m_request.tools->append(tool.toJson());
return *this;
}
InputTokensRequestBuilder &setTruncation(const QString &truncation)
{
m_request.truncation = truncation;
return *this;
}
InputTokensRequest build() const { return m_request; }
private:
InputTokensRequest m_request;
};
struct InputTokensResponse
{
QString object;
int inputTokens = 0;
static InputTokensResponse fromJson(const QJsonObject &obj)
{
InputTokensResponse result;
result.object = obj["object"].toString();
result.inputTokens = obj["input_tokens"].toInt();
return result;
}
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,127 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
namespace QodeAssist::OpenAIResponses {
/*
* REFERENCE: Item Types in List Input Items Response
* ===================================================
*
* The `data` array in ListInputItemsResponse can contain various item types.
* This file serves as a reference for all possible item types.
*
* EXISTING TYPES (already implemented):
* -------------------------------------
* - MessageOutput (in ResponseObject.hpp)
* - FunctionCall (in ResponseObject.hpp)
* - ReasoningOutput (in ResponseObject.hpp)
* - FileSearchCall (in ResponseObject.hpp)
* - CodeInterpreterCall (in ResponseObject.hpp)
* - Message (in ModelRequest.hpp) - for input messages
*
* ADDITIONAL TYPES (to be implemented if needed):
* -----------------------------------------------
*
* 1. Computer Tool Call (computer_call)
* - Computer use tool for UI automation
* - Properties: action, call_id, id, pending_safety_checks, status, type
* - Actions: click, double_click, drag, keypress, move, screenshot, scroll, type, wait
*
* 2. Computer Tool Call Output (computer_call_output)
* - Output from computer tool
* - Properties: call_id, id, output, type, acknowledged_safety_checks, status
*
* 3. Web Search Tool Call (web_search_call)
* - Web search results
* - Properties: action, id, status, type
* - Actions: search, open_page, find
*
* 4. Image Generation Call (image_generation_call)
* - AI image generation request
* - Properties: id, result (base64), status, type
*
* 5. Local Shell Call (local_shell_call)
* - Execute shell commands locally
* - Properties: action (exec), call_id, id, status, type
* - Action properties: command, env, timeout_ms, user, working_directory
*
* 6. Local Shell Call Output (local_shell_call_output)
* - Output from local shell execution
* - Properties: id, output (JSON string), type, status
*
* 7. Shell Tool Call (shell_call)
* - Managed shell environment execution
* - Properties: action, call_id, id, status, type, created_by
*
* 8. Shell Call Output (shell_call_output)
* - Output from shell tool
* - Properties: call_id, id, max_output_length, output (array), type, created_by
* - Output chunks: outcome (exit/timeout), stderr, stdout
*
* 9. Apply Patch Tool Call (apply_patch_call)
* - File diff operations
* - Properties: call_id, id, operation, status, type, created_by
* - Operations: create_file, delete_file, update_file
*
* 10. Apply Patch Tool Call Output (apply_patch_call_output)
* - Output from patch operations
* - Properties: call_id, id, status, type, created_by, output
*
* 11. MCP List Tools (mcp_list_tools)
* - List of tools from MCP server
* - Properties: id, server_label, tools (array), type, error
*
* 12. MCP Approval Request (mcp_approval_request)
* - Request for human approval
* - Properties: arguments, id, name, server_label, type
*
* 13. MCP Approval Response (mcp_approval_response)
* - Response to approval request
* - Properties: approval_request_id, approve (bool), id, type, reason
*
* 14. MCP Tool Call (mcp_call)
* - Tool invocation on MCP server
* - Properties: arguments, id, name, server_label, type
* - Optional: approval_request_id, error, output, status
*
* 15. Custom Tool Call (custom_tool_call)
* - User-defined tool call
* - Properties: call_id, input, name, type, id
*
* 16. Custom Tool Call Output (custom_tool_call_output)
* - Output from custom tool
* - Properties: call_id, output (string or array), type, id
*
* 17. Item Reference (item_reference)
* - Internal reference to another item
* - Properties: id, type
*
* USAGE:
* ------
* When parsing ListInputItemsResponse.data array:
* 1. Check item["type"] field
* 2. Use appropriate parser based on type
* 3. For existing types, use ResponseObject.hpp or ModelRequest.hpp
* 4. For additional types, implement parsers as needed
*
* EXAMPLE:
* --------
* for (const auto &itemValue : response.data) {
* const QJsonObject itemObj = itemValue.toObject();
* const QString type = itemObj["type"].toString();
*
* if (type == "message") {
* // Use MessageOutput or Message
* } else if (type == "function_call") {
* // Use FunctionCall
* } else if (type == "computer_call") {
* // Implement ComputerCall parser
* }
* // ... handle other types
* }
*/
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,150 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QStringList>
#include <optional>
namespace QodeAssist::OpenAIResponses {
enum class SortOrder { Ascending, Descending };
struct ListInputItemsRequest
{
QString responseId;
std::optional<QString> after;
std::optional<QStringList> include;
std::optional<int> limit;
std::optional<SortOrder> order;
QString buildUrl(const QString &baseUrl) const
{
QString url = QString("%1/v1/responses/%2/input_items").arg(baseUrl, responseId);
QStringList queryParams;
if (after) {
queryParams.append(QString("after=%1").arg(*after));
}
if (include && !include->isEmpty()) {
for (const auto &item : *include) {
queryParams.append(QString("include=%1").arg(item));
}
}
if (limit) {
queryParams.append(QString("limit=%1").arg(*limit));
}
if (order) {
QString orderStr = (*order == SortOrder::Ascending) ? "asc" : "desc";
queryParams.append(QString("order=%1").arg(orderStr));
}
if (!queryParams.isEmpty()) {
url += "?" + queryParams.join("&");
}
return url;
}
bool isValid() const
{
if (responseId.isEmpty()) {
return false;
}
if (limit && (*limit < 1 || *limit > 100)) {
return false;
}
return true;
}
};
class ListInputItemsRequestBuilder
{
public:
ListInputItemsRequestBuilder &setResponseId(const QString &id)
{
m_request.responseId = id;
return *this;
}
ListInputItemsRequestBuilder &setAfter(const QString &itemId)
{
m_request.after = itemId;
return *this;
}
ListInputItemsRequestBuilder &setInclude(const QStringList &include)
{
m_request.include = include;
return *this;
}
ListInputItemsRequestBuilder &addInclude(const QString &item)
{
if (!m_request.include) {
m_request.include = QStringList();
}
m_request.include->append(item);
return *this;
}
ListInputItemsRequestBuilder &setLimit(int limit)
{
m_request.limit = limit;
return *this;
}
ListInputItemsRequestBuilder &setOrder(SortOrder order)
{
m_request.order = order;
return *this;
}
ListInputItemsRequestBuilder &setAscendingOrder()
{
m_request.order = SortOrder::Ascending;
return *this;
}
ListInputItemsRequestBuilder &setDescendingOrder()
{
m_request.order = SortOrder::Descending;
return *this;
}
ListInputItemsRequest build() const { return m_request; }
private:
ListInputItemsRequest m_request;
};
struct ListInputItemsResponse
{
QJsonArray data;
QString firstId;
QString lastId;
bool hasMore = false;
QString object;
static ListInputItemsResponse fromJson(const QJsonObject &obj)
{
ListInputItemsResponse result;
result.data = obj["data"].toArray();
result.firstId = obj["first_id"].toString();
result.lastId = obj["last_id"].toString();
result.hasMore = obj["has_more"].toBool();
result.object = obj["object"].toString();
return result;
}
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,338 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QVariant>
#include <optional>
#include <variant>
namespace QodeAssist::OpenAIResponses {
enum class Role { User, Assistant, System, Developer };
enum class MessageStatus { InProgress, Completed, Incomplete };
enum class ReasoningEffort { None, Minimal, Low, Medium, High };
enum class TextFormat { Text, JsonSchema, JsonObject };
struct InputText
{
QString text;
QJsonObject toJson() const
{
return QJsonObject{{"type", "input_text"}, {"text", text}};
}
bool isValid() const noexcept { return !text.isEmpty(); }
};
struct InputImage
{
std::optional<QString> fileId;
std::optional<QString> imageUrl;
QString detail = "auto";
QJsonObject toJson() const
{
QJsonObject obj{{"type", "input_image"}, {"detail", detail}};
if (fileId)
obj["file_id"] = *fileId;
if (imageUrl)
obj["image_url"] = *imageUrl;
return obj;
}
bool isValid() const noexcept { return fileId.has_value() || imageUrl.has_value(); }
};
struct InputFile
{
std::optional<QString> fileId;
std::optional<QString> fileUrl;
std::optional<QString> fileData;
std::optional<QString> filename;
QJsonObject toJson() const
{
QJsonObject obj{{"type", "input_file"}};
if (fileId)
obj["file_id"] = *fileId;
if (fileUrl)
obj["file_url"] = *fileUrl;
if (fileData)
obj["file_data"] = *fileData;
if (filename)
obj["filename"] = *filename;
return obj;
}
bool isValid() const noexcept
{
return fileId.has_value() || fileUrl.has_value() || fileData.has_value();
}
};
class MessageContent
{
public:
MessageContent(QString text) : m_variant(std::move(text)) {}
MessageContent(InputText text) : m_variant(std::move(text)) {}
MessageContent(InputImage image) : m_variant(std::move(image)) {}
MessageContent(InputFile file) : m_variant(std::move(file)) {}
QJsonValue toJson() const
{
return std::visit([](const auto &content) -> QJsonValue {
using T = std::decay_t<decltype(content)>;
if constexpr (std::is_same_v<T, QString>) {
return content;
} else {
return content.toJson();
}
}, m_variant);
}
bool isValid() const noexcept
{
return std::visit([](const auto &content) -> bool {
using T = std::decay_t<decltype(content)>;
if constexpr (std::is_same_v<T, QString>) {
return !content.isEmpty();
} else {
return content.isValid();
}
}, m_variant);
}
private:
std::variant<QString, InputText, InputImage, InputFile> m_variant;
};
struct Message
{
Role role;
QList<MessageContent> content;
std::optional<MessageStatus> status;
QJsonObject toJson() const
{
QJsonObject obj;
obj["role"] = roleToString(role);
if (content.size() == 1) {
obj["content"] = content[0].toJson();
} else {
QJsonArray arr;
for (const auto &c : content) {
arr.append(c.toJson());
}
obj["content"] = arr;
}
if (status) {
obj["status"] = statusToString(*status);
}
return obj;
}
bool isValid() const noexcept
{
if (content.isEmpty()) {
return false;
}
for (const auto &c : content) {
if (!c.isValid()) {
return false;
}
}
return true;
}
static QString roleToString(Role r) noexcept
{
switch (r) {
case Role::User:
return "user";
case Role::Assistant:
return "assistant";
case Role::System:
return "system";
case Role::Developer:
return "developer";
}
return "user";
}
static QString statusToString(MessageStatus s) noexcept
{
switch (s) {
case MessageStatus::InProgress:
return "in_progress";
case MessageStatus::Completed:
return "completed";
case MessageStatus::Incomplete:
return "incomplete";
}
return "in_progress";
}
};
struct FunctionTool
{
QString name;
QJsonObject parameters;
std::optional<QString> description;
bool strict = true;
QJsonObject toJson() const
{
QJsonObject obj{{"type", "function"},
{"name", name},
{"parameters", parameters},
{"strict", strict}};
if (description)
obj["description"] = *description;
return obj;
}
bool isValid() const noexcept
{
return !name.isEmpty() && !parameters.isEmpty();
}
};
struct FileSearchTool
{
QStringList vectorStoreIds;
std::optional<int> maxNumResults;
std::optional<double> scoreThreshold;
QJsonObject toJson() const
{
QJsonObject obj{{"type", "file_search"}};
QJsonArray ids;
for (const auto &id : vectorStoreIds) {
ids.append(id);
}
obj["vector_store_ids"] = ids;
if (maxNumResults)
obj["max_num_results"] = *maxNumResults;
if (scoreThreshold)
obj["score_threshold"] = *scoreThreshold;
return obj;
}
bool isValid() const noexcept
{
return !vectorStoreIds.isEmpty();
}
};
struct WebSearchTool
{
QString searchContextSize = "medium";
QJsonObject toJson() const
{
return QJsonObject{{"type", "web_search"}, {"search_context_size", searchContextSize}};
}
bool isValid() const noexcept
{
return !searchContextSize.isEmpty();
}
};
struct CodeInterpreterTool
{
QString container;
QJsonObject toJson() const
{
return QJsonObject{{"type", "code_interpreter"}, {"container", container}};
}
bool isValid() const noexcept
{
return !container.isEmpty();
}
};
class Tool
{
public:
Tool(FunctionTool tool) : m_variant(std::move(tool)) {}
Tool(FileSearchTool tool) : m_variant(std::move(tool)) {}
Tool(WebSearchTool tool) : m_variant(std::move(tool)) {}
Tool(CodeInterpreterTool tool) : m_variant(std::move(tool)) {}
QJsonObject toJson() const
{
return std::visit([](const auto &t) { return t.toJson(); }, m_variant);
}
bool isValid() const noexcept
{
return std::visit([](const auto &t) { return t.isValid(); }, m_variant);
}
private:
std::variant<FunctionTool, FileSearchTool, WebSearchTool, CodeInterpreterTool> m_variant;
};
struct TextFormatOptions
{
TextFormat type = TextFormat::Text;
std::optional<QString> name;
std::optional<QJsonObject> schema;
std::optional<QString> description;
std::optional<bool> strict;
QJsonObject toJson() const
{
QJsonObject obj;
switch (type) {
case TextFormat::Text:
obj["type"] = "text";
break;
case TextFormat::JsonSchema:
obj["type"] = "json_schema";
if (name)
obj["name"] = *name;
if (schema)
obj["schema"] = *schema;
if (description)
obj["description"] = *description;
if (strict)
obj["strict"] = *strict;
break;
case TextFormat::JsonObject:
obj["type"] = "json_object";
break;
}
return obj;
}
bool isValid() const noexcept
{
if (type == TextFormat::JsonSchema) {
return name.has_value() && schema.has_value();
}
return true;
}
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,546 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <optional>
#include <variant>
#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QString>
namespace QodeAssist::OpenAIResponses {
enum class ResponseStatus { Completed, Failed, InProgress, Cancelled, Queued, Incomplete };
enum class ItemStatus { InProgress, Completed, Incomplete };
struct FileCitation
{
QString fileId;
QString filename;
int index = 0;
static FileCitation fromJson(const QJsonObject &obj)
{
return {obj["file_id"].toString(), obj["filename"].toString(), obj["index"].toInt()};
}
bool isValid() const noexcept { return !fileId.isEmpty(); }
};
struct UrlCitation
{
QString url;
QString title;
int startIndex = 0;
int endIndex = 0;
static UrlCitation fromJson(const QJsonObject &obj)
{
return {
obj["url"].toString(),
obj["title"].toString(),
obj["start_index"].toInt(),
obj["end_index"].toInt()};
}
bool isValid() const noexcept { return !url.isEmpty(); }
};
struct OutputText
{
QString text;
QList<FileCitation> fileCitations;
QList<UrlCitation> urlCitations;
static OutputText fromJson(const QJsonObject &obj)
{
OutputText result;
result.text = obj["text"].toString();
if (obj.contains("annotations")) {
const QJsonArray annotations = obj["annotations"].toArray();
result.fileCitations.reserve(annotations.size());
result.urlCitations.reserve(annotations.size());
for (const auto &annValue : annotations) {
const QJsonObject ann = annValue.toObject();
const QString type = ann["type"].toString();
if (type == "file_citation") {
result.fileCitations.append(FileCitation::fromJson(ann));
} else if (type == "url_citation") {
result.urlCitations.append(UrlCitation::fromJson(ann));
}
}
}
return result;
}
bool isValid() const noexcept { return !text.isEmpty(); }
};
struct Refusal
{
QString refusal;
static Refusal fromJson(const QJsonObject &obj)
{
return {obj["refusal"].toString()};
}
bool isValid() const noexcept { return !refusal.isEmpty(); }
};
struct MessageOutput
{
QString id;
QString role;
ItemStatus status = ItemStatus::InProgress;
QList<OutputText> outputTexts;
QList<Refusal> refusals;
static MessageOutput fromJson(const QJsonObject &obj)
{
MessageOutput result;
result.id = obj["id"].toString();
result.role = obj["role"].toString();
const QString statusStr = obj["status"].toString();
if (statusStr == "in_progress")
result.status = ItemStatus::InProgress;
else if (statusStr == "completed")
result.status = ItemStatus::Completed;
else
result.status = ItemStatus::Incomplete;
if (obj.contains("content")) {
const QJsonArray content = obj["content"].toArray();
result.outputTexts.reserve(content.size());
result.refusals.reserve(content.size());
for (const auto &item : content) {
const QJsonObject itemObj = item.toObject();
const QString type = itemObj["type"].toString();
if (type == "output_text") {
result.outputTexts.append(OutputText::fromJson(itemObj));
} else if (type == "refusal") {
result.refusals.append(Refusal::fromJson(itemObj));
}
}
}
return result;
}
bool isValid() const noexcept { return !id.isEmpty(); }
bool hasContent() const noexcept { return !outputTexts.isEmpty() || !refusals.isEmpty(); }
};
struct FunctionCall
{
QString id;
QString callId;
QString name;
QString arguments;
ItemStatus status = ItemStatus::InProgress;
static FunctionCall fromJson(const QJsonObject &obj)
{
FunctionCall result;
result.id = obj["id"].toString();
result.callId = obj["call_id"].toString();
result.name = obj["name"].toString();
result.arguments = obj["arguments"].toString();
const QString statusStr = obj["status"].toString();
if (statusStr == "in_progress")
result.status = ItemStatus::InProgress;
else if (statusStr == "completed")
result.status = ItemStatus::Completed;
else
result.status = ItemStatus::Incomplete;
return result;
}
bool isValid() const noexcept { return !id.isEmpty() && !callId.isEmpty() && !name.isEmpty(); }
};
struct ReasoningOutput
{
QString id;
ItemStatus status = ItemStatus::InProgress;
QString summaryText;
QString encryptedContent;
QList<QString> contentTexts;
static ReasoningOutput fromJson(const QJsonObject &obj)
{
ReasoningOutput result;
result.id = obj["id"].toString();
const QString statusStr = obj["status"].toString();
if (statusStr == "in_progress")
result.status = ItemStatus::InProgress;
else if (statusStr == "completed")
result.status = ItemStatus::Completed;
else
result.status = ItemStatus::Incomplete;
if (obj.contains("summary")) {
const QJsonArray summary = obj["summary"].toArray();
for (const auto &item : summary) {
const QJsonObject itemObj = item.toObject();
if (itemObj["type"].toString() == "summary_text") {
result.summaryText = itemObj["text"].toString();
break;
}
}
}
if (obj.contains("content")) {
const QJsonArray content = obj["content"].toArray();
result.contentTexts.reserve(content.size());
for (const auto &item : content) {
const QJsonObject itemObj = item.toObject();
if (itemObj["type"].toString() == "reasoning_text") {
result.contentTexts.append(itemObj["text"].toString());
}
}
}
if (obj.contains("encrypted_content")) {
result.encryptedContent = obj["encrypted_content"].toString();
}
return result;
}
bool isValid() const noexcept { return !id.isEmpty(); }
bool hasContent() const noexcept
{
return !summaryText.isEmpty() || !contentTexts.isEmpty() || !encryptedContent.isEmpty();
}
};
struct FileSearchResult
{
QString fileId;
QString filename;
QString text;
double score = 0.0;
static FileSearchResult fromJson(const QJsonObject &obj)
{
return {
obj["file_id"].toString(),
obj["filename"].toString(),
obj["text"].toString(),
obj["score"].toDouble()};
}
bool isValid() const noexcept { return !fileId.isEmpty(); }
};
struct FileSearchCall
{
QString id;
QString status;
QStringList queries;
QList<FileSearchResult> results;
static FileSearchCall fromJson(const QJsonObject &obj)
{
FileSearchCall result;
result.id = obj["id"].toString();
result.status = obj["status"].toString();
if (obj.contains("queries")) {
const QJsonArray queries = obj["queries"].toArray();
result.queries.reserve(queries.size());
for (const auto &q : queries) {
result.queries.append(q.toString());
}
}
if (obj.contains("results")) {
const QJsonArray results = obj["results"].toArray();
result.results.reserve(results.size());
for (const auto &r : results) {
result.results.append(FileSearchResult::fromJson(r.toObject()));
}
}
return result;
}
bool isValid() const noexcept { return !id.isEmpty(); }
};
struct CodeInterpreterOutput
{
QString type;
QString logs;
QString imageUrl;
static CodeInterpreterOutput fromJson(const QJsonObject &obj)
{
CodeInterpreterOutput result;
result.type = obj["type"].toString();
if (result.type == "logs") {
result.logs = obj["logs"].toString();
} else if (result.type == "image") {
result.imageUrl = obj["url"].toString();
}
return result;
}
bool isValid() const noexcept
{
return !type.isEmpty() && (!logs.isEmpty() || !imageUrl.isEmpty());
}
};
struct CodeInterpreterCall
{
QString id;
QString containerId;
std::optional<QString> code;
QString status;
QList<CodeInterpreterOutput> outputs;
static CodeInterpreterCall fromJson(const QJsonObject &obj)
{
CodeInterpreterCall result;
result.id = obj["id"].toString();
result.containerId = obj["container_id"].toString();
result.status = obj["status"].toString();
if (obj.contains("code") && !obj["code"].isNull()) {
result.code = obj["code"].toString();
}
if (obj.contains("outputs")) {
const QJsonArray outputs = obj["outputs"].toArray();
result.outputs.reserve(outputs.size());
for (const auto &o : outputs) {
result.outputs.append(CodeInterpreterOutput::fromJson(o.toObject()));
}
}
return result;
}
bool isValid() const noexcept { return !id.isEmpty() && !containerId.isEmpty(); }
};
class OutputItem
{
public:
enum class Type { Message, FunctionCall, Reasoning, FileSearch, CodeInterpreter, Unknown };
explicit OutputItem(const MessageOutput &msg)
: m_type(Type::Message)
, m_data(msg)
{}
explicit OutputItem(const FunctionCall &call)
: m_type(Type::FunctionCall)
, m_data(call)
{}
explicit OutputItem(const ReasoningOutput &reasoning)
: m_type(Type::Reasoning)
, m_data(reasoning)
{}
explicit OutputItem(const FileSearchCall &search)
: m_type(Type::FileSearch)
, m_data(search)
{}
explicit OutputItem(const CodeInterpreterCall &interpreter)
: m_type(Type::CodeInterpreter)
, m_data(interpreter)
{}
Type type() const { return m_type; }
const MessageOutput *asMessage() const
{
return std::holds_alternative<MessageOutput>(m_data) ? &std::get<MessageOutput>(m_data)
: nullptr;
}
const FunctionCall *asFunctionCall() const
{
return std::holds_alternative<FunctionCall>(m_data) ? &std::get<FunctionCall>(m_data)
: nullptr;
}
const ReasoningOutput *asReasoning() const
{
return std::holds_alternative<ReasoningOutput>(m_data) ? &std::get<ReasoningOutput>(m_data)
: nullptr;
}
const FileSearchCall *asFileSearch() const
{
return std::holds_alternative<FileSearchCall>(m_data) ? &std::get<FileSearchCall>(m_data)
: nullptr;
}
const CodeInterpreterCall *asCodeInterpreter() const
{
return std::holds_alternative<CodeInterpreterCall>(m_data)
? &std::get<CodeInterpreterCall>(m_data)
: nullptr;
}
static OutputItem fromJson(const QJsonObject &obj)
{
const QString type = obj["type"].toString();
if (type == "message") {
return OutputItem(MessageOutput::fromJson(obj));
} else if (type == "function_call") {
return OutputItem(FunctionCall::fromJson(obj));
} else if (type == "reasoning") {
return OutputItem(ReasoningOutput::fromJson(obj));
} else if (type == "file_search_call") {
return OutputItem(FileSearchCall::fromJson(obj));
} else if (type == "code_interpreter_call") {
return OutputItem(CodeInterpreterCall::fromJson(obj));
}
return OutputItem(MessageOutput{});
}
private:
Type m_type;
std::variant<MessageOutput, FunctionCall, ReasoningOutput, FileSearchCall, CodeInterpreterCall>
m_data;
};
struct Usage
{
int inputTokens = 0;
int outputTokens = 0;
int totalTokens = 0;
static Usage fromJson(const QJsonObject &obj)
{
return {
obj["input_tokens"].toInt(),
obj["output_tokens"].toInt(),
obj["total_tokens"].toInt()
};
}
bool isValid() const noexcept { return totalTokens > 0; }
};
struct ResponseError
{
QString code;
QString message;
static ResponseError fromJson(const QJsonObject &obj)
{
return {obj["code"].toString(), obj["message"].toString()};
}
bool isValid() const noexcept { return !code.isEmpty() && !message.isEmpty(); }
};
struct Response
{
QString id;
qint64 createdAt = 0;
QString model;
ResponseStatus status = ResponseStatus::InProgress;
QList<OutputItem> output;
QString outputText;
std::optional<Usage> usage;
std::optional<ResponseError> error;
std::optional<QString> conversationId;
static Response fromJson(const QJsonObject &obj)
{
Response result;
result.id = obj["id"].toString();
result.createdAt = obj["created_at"].toInteger();
result.model = obj["model"].toString();
const QString statusStr = obj["status"].toString();
if (statusStr == "completed")
result.status = ResponseStatus::Completed;
else if (statusStr == "failed")
result.status = ResponseStatus::Failed;
else if (statusStr == "in_progress")
result.status = ResponseStatus::InProgress;
else if (statusStr == "cancelled")
result.status = ResponseStatus::Cancelled;
else if (statusStr == "queued")
result.status = ResponseStatus::Queued;
else
result.status = ResponseStatus::Incomplete;
if (obj.contains("output")) {
const QJsonArray output = obj["output"].toArray();
result.output.reserve(output.size());
for (const auto &item : output) {
result.output.append(OutputItem::fromJson(item.toObject()));
}
}
if (obj.contains("output_text")) {
result.outputText = obj["output_text"].toString();
}
if (obj.contains("usage")) {
result.usage = Usage::fromJson(obj["usage"].toObject());
}
if (obj.contains("error")) {
result.error = ResponseError::fromJson(obj["error"].toObject());
}
if (obj.contains("conversation")) {
const QJsonObject conv = obj["conversation"].toObject();
result.conversationId = conv["id"].toString();
}
return result;
}
QString getAggregatedText() const
{
if (!outputText.isEmpty()) {
return outputText;
}
QString aggregated;
for (const auto &item : output) {
if (const auto *msg = item.asMessage()) {
for (const auto &text : msg->outputTexts) {
aggregated += text.text;
}
}
}
return aggregated;
}
bool isValid() const noexcept { return !id.isEmpty(); }
bool hasError() const noexcept { return error.has_value(); }
bool isCompleted() const noexcept { return status == ResponseStatus::Completed; }
bool isFailed() const noexcept { return status == ResponseStatus::Failed; }
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -1,230 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "OpenAIResponsesMessage.hpp"
#include "OpenAIResponses/ResponseObject.hpp"
#include "logger/Logger.hpp"
#include <QJsonArray>
namespace QodeAssist::Providers {
OpenAIResponsesMessage::OpenAIResponsesMessage(QObject *parent)
: QObject(parent)
{}
void OpenAIResponsesMessage::handleItemDelta(const QJsonObject &item)
{
using namespace QodeAssist::OpenAIResponses;
const QString itemType = item["type"].toString();
if (itemType == "message" || (itemType.isEmpty() && item.contains("content"))) {
OutputItem outputItem = OutputItem::fromJson(item);
if (const auto *msg = outputItem.asMessage()) {
for (const auto &outputText : msg->outputTexts) {
if (!outputText.text.isEmpty()) {
auto textItem = getOrCreateTextItem();
textItem->appendText(outputText.text);
}
}
}
}
}
void OpenAIResponsesMessage::handleToolCallStart(const QString &callId, const QString &name)
{
auto toolContent = new PluginLLMCore::ToolUseContent(callId, name);
toolContent->setParent(this);
m_items.append(toolContent);
m_toolCalls[callId] = toolContent;
m_pendingToolArguments[callId] = "";
}
void OpenAIResponsesMessage::handleToolCallDelta(const QString &callId, const QString &argumentsDelta)
{
if (m_pendingToolArguments.contains(callId)) {
m_pendingToolArguments[callId] += argumentsDelta;
}
}
void OpenAIResponsesMessage::handleToolCallComplete(const QString &callId)
{
if (m_pendingToolArguments.contains(callId) && m_toolCalls.contains(callId)) {
QString jsonArgs = m_pendingToolArguments[callId];
QJsonObject argsObject;
if (!jsonArgs.isEmpty()) {
QJsonDocument doc = QJsonDocument::fromJson(jsonArgs.toUtf8());
if (doc.isObject()) {
argsObject = doc.object();
}
}
m_toolCalls[callId]->setInput(argsObject);
m_pendingToolArguments.remove(callId);
}
}
void OpenAIResponsesMessage::handleReasoningStart(const QString &itemId)
{
auto thinkingContent = new PluginLLMCore::ThinkingContent();
thinkingContent->setParent(this);
m_items.append(thinkingContent);
m_thinkingBlocks[itemId] = thinkingContent;
}
void OpenAIResponsesMessage::handleReasoningDelta(const QString &itemId, const QString &text)
{
if (m_thinkingBlocks.contains(itemId)) {
m_thinkingBlocks[itemId]->appendThinking(text);
}
}
void OpenAIResponsesMessage::handleReasoningComplete(const QString &itemId)
{
Q_UNUSED(itemId);
}
void OpenAIResponsesMessage::handleStatus(const QString &status)
{
m_status = status;
updateStateFromStatus();
}
QList<QJsonObject> OpenAIResponsesMessage::toItemsFormat() const
{
QList<QJsonObject> items;
QString textContent;
QList<PluginLLMCore::ToolUseContent *> toolCalls;
for (const auto *block : m_items) {
if (const auto *text = qobject_cast<const PluginLLMCore::TextContent *>(block)) {
textContent += text->text();
} else if (auto *tool = qobject_cast<PluginLLMCore::ToolUseContent *>(
const_cast<PluginLLMCore::ContentBlock *>(block))) {
toolCalls.append(tool);
}
}
if (!textContent.isEmpty()) {
QJsonObject message;
message["role"] = "assistant";
message["content"] = textContent;
items.append(message);
}
for (const auto *tool : toolCalls) {
QJsonObject functionCallItem;
functionCallItem["type"] = "function_call";
functionCallItem["call_id"] = tool->id();
functionCallItem["name"] = tool->name();
functionCallItem["arguments"] = QString::fromUtf8(
QJsonDocument(tool->input()).toJson(QJsonDocument::Compact));
items.append(functionCallItem);
}
return items;
}
QList<PluginLLMCore::ToolUseContent *> OpenAIResponsesMessage::getCurrentToolUseContent() const
{
QList<PluginLLMCore::ToolUseContent *> toolBlocks;
for (auto *block : m_items) {
if (auto *toolContent = qobject_cast<PluginLLMCore::ToolUseContent *>(block)) {
toolBlocks.append(toolContent);
}
}
return toolBlocks;
}
QList<PluginLLMCore::ThinkingContent *> OpenAIResponsesMessage::getCurrentThinkingContent() const
{
QList<PluginLLMCore::ThinkingContent *> thinkingBlocks;
for (auto *block : m_items) {
if (auto *thinkingContent = qobject_cast<PluginLLMCore::ThinkingContent *>(block)) {
thinkingBlocks.append(thinkingContent);
}
}
return thinkingBlocks;
}
QJsonArray OpenAIResponsesMessage::createToolResultItems(const QHash<QString, QString> &toolResults) const
{
QJsonArray items;
for (const auto *toolContent : getCurrentToolUseContent()) {
if (toolResults.contains(toolContent->id())) {
QJsonObject toolResultItem;
toolResultItem["type"] = "function_call_output";
toolResultItem["call_id"] = toolContent->id();
toolResultItem["output"] = toolResults[toolContent->id()];
items.append(toolResultItem);
}
}
return items;
}
QString OpenAIResponsesMessage::accumulatedText() const
{
QString text;
for (const auto *block : m_items) {
if (const auto *textContent = qobject_cast<const PluginLLMCore::TextContent *>(block)) {
text += textContent->text();
}
}
return text;
}
void OpenAIResponsesMessage::updateStateFromStatus()
{
using namespace QodeAssist::OpenAIResponses;
if (m_status == "completed") {
if (!getCurrentToolUseContent().isEmpty()) {
m_state = PluginLLMCore::MessageState::RequiresToolExecution;
} else {
m_state = PluginLLMCore::MessageState::Complete;
}
} else if (m_status == "in_progress") {
m_state = PluginLLMCore::MessageState::Building;
} else if (m_status == "failed" || m_status == "cancelled" || m_status == "incomplete") {
m_state = PluginLLMCore::MessageState::Final;
} else {
m_state = PluginLLMCore::MessageState::Building;
}
}
PluginLLMCore::TextContent *OpenAIResponsesMessage::getOrCreateTextItem()
{
for (auto *block : m_items) {
if (auto *textContent = qobject_cast<PluginLLMCore::TextContent *>(block)) {
return textContent;
}
}
auto *textContent = new PluginLLMCore::TextContent();
textContent->setParent(this);
m_items.append(textContent);
return textContent;
}
void OpenAIResponsesMessage::startNewContinuation()
{
m_toolCalls.clear();
m_thinkingBlocks.clear();
qDeleteAll(m_items);
m_items.clear();
m_pendingToolArguments.clear();
m_status.clear();
m_state = PluginLLMCore::MessageState::Building;
}
} // namespace QodeAssist::Providers

View File

@@ -1,51 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <pluginllmcore/ContentBlocks.hpp>
namespace QodeAssist::Providers {
class OpenAIResponsesMessage : public QObject
{
Q_OBJECT
public:
explicit OpenAIResponsesMessage(QObject *parent = nullptr);
void handleItemDelta(const QJsonObject &item);
void handleToolCallStart(const QString &callId, const QString &name);
void handleToolCallDelta(const QString &callId, const QString &argumentsDelta);
void handleToolCallComplete(const QString &callId);
void handleReasoningStart(const QString &itemId);
void handleReasoningDelta(const QString &itemId, const QString &text);
void handleReasoningComplete(const QString &itemId);
void handleStatus(const QString &status);
QList<QJsonObject> toItemsFormat() const;
QJsonArray createToolResultItems(const QHash<QString, QString> &toolResults) const;
PluginLLMCore::MessageState state() const noexcept { return m_state; }
QString accumulatedText() const;
QList<PluginLLMCore::ToolUseContent *> getCurrentToolUseContent() const;
QList<PluginLLMCore::ThinkingContent *> getCurrentThinkingContent() const;
bool hasToolCalls() const noexcept { return !m_toolCalls.isEmpty(); }
bool hasThinkingContent() const noexcept { return !m_thinkingBlocks.isEmpty(); }
void startNewContinuation();
private:
QString m_status;
PluginLLMCore::MessageState m_state = PluginLLMCore::MessageState::Building;
QList<PluginLLMCore::ContentBlock *> m_items;
QHash<QString, QString> m_pendingToolArguments;
QHash<QString, PluginLLMCore::ToolUseContent *> m_toolCalls;
QHash<QString, PluginLLMCore::ThinkingContent *> m_thinkingBlocks;
void updateStateFromStatus();
PluginLLMCore::TextContent *getOrCreateTextItem();
};
} // namespace QodeAssist::Providers

View File

@@ -1,239 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "OpenAIResponses/ModelRequest.hpp"
#include <QJsonObject>
#include <QList>
#include <QMap>
#include <QString>
namespace QodeAssist::OpenAIResponses {
class RequestBuilder
{
public:
RequestBuilder() = default;
RequestBuilder &setModel(QString model)
{
m_model = std::move(model);
return *this;
}
RequestBuilder &addMessage(Role role, QString content)
{
Message msg;
msg.role = role;
msg.content.append(MessageContent(std::move(content)));
m_messages.append(std::move(msg));
return *this;
}
RequestBuilder &addMessage(Message msg)
{
m_messages.append(std::move(msg));
return *this;
}
RequestBuilder &setInstructions(QString instructions)
{
m_instructions = std::move(instructions);
return *this;
}
RequestBuilder &addTool(Tool tool)
{
m_tools.append(std::move(tool));
return *this;
}
RequestBuilder &setTemperature(double temp) noexcept
{
m_temperature = temp;
return *this;
}
RequestBuilder &setTopP(double topP) noexcept
{
m_topP = topP;
return *this;
}
RequestBuilder &setMaxOutputTokens(int tokens) noexcept
{
m_maxOutputTokens = tokens;
return *this;
}
RequestBuilder &setStream(bool stream) noexcept
{
m_stream = stream;
return *this;
}
RequestBuilder &setStore(bool store) noexcept
{
m_store = store;
return *this;
}
RequestBuilder &setTextFormat(TextFormatOptions format)
{
m_textFormat = std::move(format);
return *this;
}
RequestBuilder &setReasoningEffort(ReasoningEffort effort) noexcept
{
m_reasoningEffort = effort;
return *this;
}
RequestBuilder &setMetadata(QMap<QString, QVariant> metadata)
{
m_metadata = std::move(metadata);
return *this;
}
RequestBuilder &setIncludeReasoningContent(bool include) noexcept
{
m_includeReasoningContent = include;
return *this;
}
RequestBuilder &clear() noexcept
{
m_model.clear();
m_messages.clear();
m_instructions.reset();
m_tools.clear();
m_temperature.reset();
m_topP.reset();
m_maxOutputTokens.reset();
m_stream = false;
m_store.reset();
m_textFormat.reset();
m_reasoningEffort.reset();
m_includeReasoningContent = false;
m_metadata.clear();
return *this;
}
QJsonObject toJson() const
{
QJsonObject obj;
if (!m_model.isEmpty()) {
obj["model"] = m_model;
}
if (!m_messages.isEmpty()) {
if (m_messages.size() == 1 && m_messages[0].role == Role::User
&& m_messages[0].content.size() == 1) {
obj["input"] = m_messages[0].content[0].toJson();
} else {
QJsonArray input;
for (const auto &msg : m_messages) {
input.append(msg.toJson());
}
obj["input"] = input;
}
}
if (m_instructions) {
obj["instructions"] = *m_instructions;
}
if (!m_tools.isEmpty()) {
QJsonArray tools;
for (const auto &tool : m_tools) {
tools.append(tool.toJson());
}
obj["tools"] = tools;
}
if (m_temperature) {
obj["temperature"] = *m_temperature;
}
if (m_topP) {
obj["top_p"] = *m_topP;
}
if (m_maxOutputTokens) {
obj["max_output_tokens"] = *m_maxOutputTokens;
}
obj["stream"] = m_stream;
if (m_store) {
obj["store"] = *m_store;
}
if (m_textFormat) {
QJsonObject textObj;
textObj["format"] = m_textFormat->toJson();
obj["text"] = textObj;
}
if (m_reasoningEffort) {
QJsonObject reasoning;
reasoning["effort"] = effortToString(*m_reasoningEffort);
obj["reasoning"] = reasoning;
}
if (m_includeReasoningContent) {
QJsonArray include;
include.append("reasoning.encrypted_content");
obj["include"] = include;
}
if (!m_metadata.isEmpty()) {
QJsonObject metadata;
for (auto it = m_metadata.constBegin(); it != m_metadata.constEnd(); ++it) {
metadata[it.key()] = QJsonValue::fromVariant(it.value());
}
obj["metadata"] = metadata;
}
return obj;
}
private:
QString m_model;
QList<Message> m_messages;
std::optional<QString> m_instructions;
QList<Tool> m_tools;
std::optional<double> m_temperature;
std::optional<double> m_topP;
std::optional<int> m_maxOutputTokens;
bool m_stream = false;
std::optional<bool> m_store;
std::optional<TextFormatOptions> m_textFormat;
std::optional<ReasoningEffort> m_reasoningEffort;
bool m_includeReasoningContent = false;
QMap<QString, QVariant> m_metadata;
static QString effortToString(ReasoningEffort e)
{
switch (e) {
case ReasoningEffort::None:
return "none";
case ReasoningEffort::Minimal:
return "minimal";
case ReasoningEffort::Low:
return "low";
case ReasoningEffort::Medium:
return "medium";
case ReasoningEffort::High:
return "high";
}
return "medium";
}
};
} // namespace QodeAssist::OpenAIResponses

View File

@@ -0,0 +1,23 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
namespace QodeAssist::Providers {
// LM Studio presents its OpenAI-compatible API as <host>/v1/..., while the
// OpenAI-style clients expect the base URL to already include /v1. Accept the
// configured URL either with or without the /v1 suffix and return it normalized.
inline QString ensureOpenAIV1Base(const QString &url)
{
QString base = url.trimmed();
while (base.endsWith(QLatin1Char('/')))
base.chop(1);
if (!base.endsWith(QStringLiteral("/v1")))
base += QStringLiteral("/v1");
return base;
}
} // namespace QodeAssist::Providers

View File

@@ -10,10 +10,13 @@
#include <coreplugin/actionmanager/actionmanager.h>
#include <coreplugin/actionmanager/command.h>
#include <coreplugin/coreconstants.h>
#include <coreplugin/editormanager/documentmodel.h>
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/icontext.h>
#include <coreplugin/icore.h>
#include <coreplugin/messagemanager.h>
#include <coreplugin/modemanager.h>
#include <coreplugin/navigationwidget.h>
#include <coreplugin/statusbarmanager.h>
#include <extensionsystem/iplugin.h>
#include <languageclient/languageclientmanager.h>
@@ -31,6 +34,8 @@
#include "QodeAssistClient.hpp"
#include "UpdateStatusWidget.hpp"
#include "Version.hpp"
#include "chat/ChatEditor.hpp"
#include "chat/ChatEditorFactory.hpp"
#include "chat/ChatOutputPane.h"
#include "chat/NavigationPanel.hpp"
#include "context/DocumentReaderQtCreator.hpp"
@@ -39,6 +44,8 @@
#include "logger/RequestPerformanceLogger.hpp"
#include "mcp/McpClientsManager.hpp"
#include "mcp/McpServerManager.hpp"
#include "sources/skills/SkillsManager.hpp"
#include "tools/ToolsRegistration.hpp"
#include "providers/Providers.hpp"
#include "settings/ChatAssistantSettings.hpp"
#include "settings/GeneralSettings.hpp"
@@ -50,6 +57,11 @@
#include "widgets/QuickRefactorDialog.hpp"
#include <ChatView/ChatView.hpp>
#include <ChatView/ChatFileManager.hpp>
#include <ChatView/ChatRootView.hpp>
#include <ChatView/ChatWidget.hpp>
#include <ChatView/SessionFileRegistry.hpp>
#include <coreplugin/editormanager/editormanager.h>
#include <QUuid>
#include <coreplugin/actionmanager/actioncontainer.h>
#include <coreplugin/actionmanager/actionmanager.h>
#include <texteditor/textdocument.h>
@@ -84,6 +96,7 @@ public:
if (m_navigationPanel) {
delete m_navigationPanel;
}
delete m_chatEditorFactory;
}
void loadTranslations()
@@ -151,12 +164,30 @@ public:
UpdateDialog::checkForUpdatesAndShow(Core::ICore::mainWindow());
});
m_engine = new QQmlEngine{this};
m_sessionFileRegistry = new Chat::SessionFileRegistry{this};
m_skillsManager = new Skills::SkillsManager{this};
{
auto &providers = PluginLLMCore::ProvidersManager::instance();
for (const QString &providerName : providers.providersNames()) {
if (auto *provider = providers.getProviderByName(providerName)) {
if (auto *toolsManager = provider->toolsManager())
Tools::registerSkillTool(toolsManager, m_skillsManager);
}
}
}
if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) {
m_chatOutputPane = new Chat::ChatOutputPane(this);
m_chatOutputPane = new Chat::ChatOutputPane{
m_engine, m_sessionFileRegistry, m_skillsManager};
}
if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) {
m_navigationPanel = new Chat::NavigationPanel();
m_navigationPanel = new Chat::NavigationPanel{
m_engine, m_sessionFileRegistry, m_skillsManager};
}
m_chatEditorFactory = new Chat::ChatEditorFactory{
m_engine, m_sessionFileRegistry, m_skillsManager};
Settings::setupProjectPanel();
ConfigurationManager::instance().init();
@@ -196,26 +227,23 @@ public:
}
});
ActionBuilder showChatViewAction(this, "QodeAssist.ShowChatView");
ActionBuilder showChatViewAction(this, Constants::QODE_ASSIST_SHOW_CHAT_ACTION);
const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W);
showChatViewAction.setDefaultKeySequence(showChatViewShortcut);
showChatViewAction.setToolTip(Tr::tr("Show QodeAssist Chat"));
showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat as an editor tab"));
showChatViewAction.setText(Tr::tr("Show QodeAssist Chat"));
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
showChatViewAction.addOnTriggered(this, [this] {
if (!m_chatView) {
m_chatView.reset(new Chat::ChatView());
}
if (!m_chatView->isVisible()) {
m_chatView->show();
}
m_chatView->raise();
m_chatView->requestActivate();
});
showChatViewAction.addOnTriggered(this, [this] { openChatInEditor(); });
m_statusWidget->setChatButtonAction(showChatViewAction.contextAction());
m_chatButtonMenu = new QMenu(m_statusWidget);
connect(
m_chatButtonMenu,
&QMenu::aboutToShow,
this,
&QodeAssistPlugin::rebuildChatButtonMenu);
m_statusWidget->setChatButtonMenu(m_chatButtonMenu);
ActionBuilder closeChatViewAction(this, "QodeAssist.CloseChatView");
const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut);
@@ -228,6 +256,38 @@ public:
}
});
ActionBuilder openChatWindowAction(this, Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION);
openChatWindowAction.setText(Tr::tr("Open QodeAssist Chat in Separate Window"));
openChatWindowAction.setToolTip(Tr::tr("Open the QodeAssist chat in a separate window"));
openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); });
ActionBuilder newChatAction(this, Constants::QODE_ASSIST_NEW_CHAT_ACTION);
newChatAction.setText(Tr::tr("New QodeAssist Chat"));
newChatAction.setToolTip(Tr::tr("Open a fresh chat in a new editor tab"));
newChatAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
newChatAction.addOnTriggered(this, [this] { openNewChatInEditor(); });
ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE);
sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message"));
sendMessageAction.setToolTip(Tr::tr("Send the current message to the LLM"));
sendMessageAction.setDefaultKeySequence(QKeySequence(Qt::CTRL | Qt::Key_Return));
sendMessageAction.addOnTriggered(this, [] {
if (auto chatWidget = Chat::ChatWidget::focusedInstance())
chatWidget->sendMessage();
});
ActionBuilder clearSessionAction(this, Constants::QODE_ASSIST_CHAT_CLEAR_SESSION);
clearSessionAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
clearSessionAction.setText(Tr::tr("Clear QodeAssist Chat Session"));
clearSessionAction.setToolTip(Tr::tr("Clear the current chat session"));
clearSessionAction.setDefaultKeySequence(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_L));
clearSessionAction.addOnTriggered(this, [] {
if (auto chatWidget = Chat::ChatWidget::focusedInstance())
chatWidget->clearSession();
});
Core::ActionContainer *editorContextMenu = Core::ActionManager::actionContainer(
TextEditor::Constants::M_STANDARDCONTEXTMENU);
if (editorContextMenu) {
@@ -271,6 +331,125 @@ public:
}
private:
void openChatInEditor()
{
if (auto existing = findExistingChatEditor()) {
Core::EditorManager::activateEditor(existing);
existing->consumePendingChatFile();
return;
}
QString title = Tr::tr("QodeAssist Chat");
Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
chatEditor->consumePendingChatFile();
}
void openNewChatInEditor()
{
QString title = Tr::tr("QodeAssist Chat");
Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
// For the "New Chat" button pending is empty (no-op). For relocate-to-editor it
// carries the handed-off chat file and gets loaded into the freshly opened tab.
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
chatEditor->consumePendingChatFile();
}
Chat::ChatEditor *findExistingChatEditor() const
{
const auto entries = Core::DocumentModel::entries();
for (auto *entry : entries) {
if (!entry || !entry->document)
continue;
if (entry->document->id() != Constants::QODE_ASSIST_CHAT_EDITOR_ID)
continue;
const auto editors = Core::DocumentModel::editorsForDocument(entry->document);
for (auto *editor : editors) {
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
return chatEditor;
}
}
return nullptr;
}
void openChatInWindow()
{
if (!m_chatView)
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry, m_skillsManager});
if (!m_chatView->isVisible())
m_chatView->show();
m_chatView->raise();
m_chatView->requestActivate();
if (auto rootView = qobject_cast<Chat::ChatRootView *>(m_chatView->rootObject()))
rootView->consumePendingChatFile();
}
void setChatInBottomPaneEnabled(bool enabled)
{
if (enabled && !m_chatOutputPane)
m_chatOutputPane = new Chat::ChatOutputPane{
m_engine, m_sessionFileRegistry, m_skillsManager};
else if (!enabled && m_chatOutputPane)
delete m_chatOutputPane;
Settings::chatAssistantSettings().enableChatInBottomToolBar.setValue(enabled);
Settings::chatAssistantSettings().writeSettings();
}
void setChatInSidebarEnabled(bool enabled)
{
if (enabled && !m_navigationPanel)
m_navigationPanel = new Chat::NavigationPanel{
m_engine, m_sessionFileRegistry, m_skillsManager};
else if (!enabled && m_navigationPanel)
delete m_navigationPanel;
Settings::chatAssistantSettings().enableChatInNavigationPanel.setValue(enabled);
Settings::chatAssistantSettings().writeSettings();
}
void rebuildChatButtonMenu()
{
if (!m_chatButtonMenu)
return;
m_chatButtonMenu->clear();
QAction *paneAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Bottom Panel"));
paneAction->setCheckable(true);
paneAction->setChecked(m_chatOutputPane != nullptr);
connect(paneAction, &QAction::toggled, this, [this](bool on) {
setChatInBottomPaneEnabled(on);
});
QAction *sidebarAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Sidebar"));
sidebarAction->setCheckable(true);
sidebarAction->setChecked(m_navigationPanel != nullptr);
connect(sidebarAction, &QAction::toggled, this, [this](bool on) {
setChatInSidebarEnabled(on);
});
m_chatButtonMenu->addSeparator();
if (m_chatView && m_chatView->isVisible()) {
QAction *editorAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Editor"));
connect(editorAction, &QAction::triggered, this, [this] {
if (m_chatView)
m_chatView->close();
openChatInEditor();
});
} else {
QAction *windowAction
= m_chatButtonMenu->addAction(Tr::tr("Open Chat in Separate Window"));
connect(windowAction, &QAction::triggered, this, [this] { openChatInWindow(); });
}
}
void checkForUpdates()
{
connect(
@@ -297,11 +476,16 @@ private:
RequestPerformanceLogger m_performanceLogger;
QPointer<Chat::ChatOutputPane> m_chatOutputPane;
QPointer<Chat::NavigationPanel> m_navigationPanel;
QPointer<Chat::SessionFileRegistry> m_sessionFileRegistry;
Chat::ChatEditorFactory *m_chatEditorFactory{nullptr};
QPointer<QMenu> m_chatButtonMenu;
QPointer<PluginUpdater> m_updater;
UpdateStatusWidget *m_statusWidget{nullptr};
QString m_lastRefactorInstructions;
QScopedPointer<Chat::ChatView> m_chatView;
QPointer<Mcp::McpServerManager> m_mcpServerManager;
QPointer<QQmlEngine> m_engine;
QPointer<Skills::SkillsManager> m_skillsManager;
};
} // namespace QodeAssist::Internal

View File

@@ -9,6 +9,7 @@ add_library(QodeAssistSettings STATIC
ChatAssistantSettings.hpp ChatAssistantSettings.cpp
QuickRefactorSettings.hpp QuickRefactorSettings.cpp
ToolsSettings.hpp ToolsSettings.cpp
SkillsSettings.hpp SkillsSettings.cpp
McpSettings.hpp McpSettings.cpp
SettingsDialog.hpp SettingsDialog.cpp
ProjectSettings.hpp ProjectSettings.cpp
@@ -30,5 +31,6 @@ target_link_libraries(QodeAssistSettings
QtCreator::Core
QtCreator::Utils
QodeAssistLogger
Skills
)
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -29,14 +29,6 @@ ChatAssistantSettings::ChatAssistantSettings()
setDisplayName(Tr::tr("Chat Assistant"));
// Chat Settings
chatTokensThreshold.setSettingsKey(Constants::CA_TOKENS_THRESHOLD);
chatTokensThreshold.setLabelText(Tr::tr("Chat history token limit:"));
chatTokensThreshold.setToolTip(Tr::tr("Maximum number of tokens in chat history. When "
"exceeded, oldest messages will be removed."));
chatTokensThreshold.setRange(1, 99999999);
chatTokensThreshold.setDefaultValue(20000);
linkOpenFiles.setSettingsKey(Constants::CA_LINK_OPEN_FILES);
linkOpenFiles.setLabelText(Tr::tr("Sync open files with assistant by default"));
linkOpenFiles.setDefaultValue(false);
@@ -58,6 +50,18 @@ ChatAssistantSettings::ChatAssistantSettings()
enableChatTools.setToolTip(Tr::tr("When enabled, AI can use tools to read files, search project, and build code"));
enableChatTools.setDefaultValue(false);
autoCompress.setSettingsKey(Constants::CA_AUTO_COMPRESS);
autoCompress.setLabelText(Tr::tr("Auto-compress chat when session tokens exceed:"));
autoCompress.setToolTip(Tr::tr(
"After each assistant response, if the running session token total exceeds the "
"threshold, the chat is summarized and a new compressed chat is started "
"automatically. The original chat is preserved on disk."));
autoCompress.setDefaultValue(false);
autoCompressThreshold.setSettingsKey(Constants::CA_AUTO_COMPRESS_THRESHOLD);
autoCompressThreshold.setRange(1000, 99999999);
autoCompressThreshold.setDefaultValue(40000);
// General Parameters Settings
temperature.setSettingsKey(Constants::CA_TEMPERATURE);
temperature.setLabelText(Tr::tr("Temperature:"));
@@ -292,11 +296,9 @@ ChatAssistantSettings::ChatAssistantSettings()
Group{
title(Tr::tr("Chat Settings")),
Column{
Row{chatTokensThreshold, Stretch{1}},
linkOpenFiles,
autosave,
enableChatInBottomToolBar,
enableChatInNavigationPanel}},
Row{autoCompress, autoCompressThreshold, Stretch{1}}}},
Space{8},
Group{
title(Tr::tr("Tools")),
@@ -348,7 +350,8 @@ void ChatAssistantSettings::resetSettingsToDefaults()
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Yes) {
resetAspect(chatTokensThreshold);
resetAspect(autoCompress);
resetAspect(autoCompressThreshold);
resetAspect(temperature);
resetAspect(maxTokens);
resetAspect(useTopP);

View File

@@ -18,12 +18,13 @@ public:
ButtonAspect resetToDefaults{this};
// Chat settings
Utils::IntegerAspect chatTokensThreshold{this};
Utils::BoolAspect linkOpenFiles{this};
Utils::BoolAspect autosave{this};
Utils::BoolAspect enableChatInBottomToolBar{this};
Utils::BoolAspect enableChatInNavigationPanel{this};
Utils::BoolAspect enableChatTools{this};
Utils::BoolAspect autoCompress{this};
Utils::IntegerAspect autoCompressThreshold{this};
// General Parameters Settings
Utils::DoubleAspect temperature{this};

Some files were not shown because too many files have changed in this diff Show More