Compare commits
168 Commits
v0.9.0
...
dev-releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa748b14a | ||
|
|
f499be278d | ||
|
|
231a6a0215 | ||
|
|
69672deb45 | ||
|
|
f36173d932 | ||
|
|
e65ac23e66 | ||
|
|
7bfe9d6f0e | ||
|
|
05fe38e289 | ||
|
|
2c9475cddf | ||
|
|
3179c0c358 | ||
|
|
c151c5030b | ||
|
|
98a618cf87 | ||
|
|
6220308a93 | ||
|
|
02c11ee5a0 | ||
|
|
abb3351246 | ||
|
|
57eeb32ceb | ||
|
|
74eed49fb4 | ||
|
|
43a30281b6 | ||
|
|
bf4307c459 | ||
|
|
6df70e608b | ||
|
|
ee1bf4ffe5 | ||
|
|
aaca9e2a0b | ||
|
|
f2aae9d37f | ||
|
|
dcf5796ad7 | ||
|
|
033c0e8652 | ||
|
|
ea67ba0e2a | ||
|
|
0cf915c4a5 | ||
|
|
99caa853d5 | ||
|
|
278624d412 | ||
|
|
f8adf4d264 | ||
|
|
bfcd8dc1fb | ||
|
|
33321b2499 | ||
|
|
362533a5c0 | ||
|
|
d180d189e4 | ||
|
|
0774084ad9 | ||
|
|
282f48d9fb | ||
|
|
8cbeb7132e | ||
|
|
af898bd255 | ||
|
|
66e25300e8 | ||
|
|
fcc651fd75 | ||
|
|
dc016ce533 | ||
|
|
725de4a2c3 | ||
|
|
8d3313d16b | ||
|
|
abdcab3c7d | ||
|
|
abadc2262c | ||
|
|
31ad99af61 | ||
|
|
fb887967ed | ||
|
|
97236c6069 | ||
|
|
51ebe3e523 | ||
|
|
e193d1e1fa | ||
|
|
ca3baa7597 | ||
|
|
b33a1c2d43 | ||
|
|
c4e34bb3d9 | ||
|
|
b9e0b5a00c | ||
|
|
3f4bda51cd | ||
|
|
7483c78777 | ||
|
|
a3ad314cd4 | ||
|
|
74c899c8c3 | ||
|
|
6addcedfd0 | ||
|
|
eb7fc2f7b4 | ||
|
|
a06320d1c4 | ||
|
|
b1ca6823b8 | ||
|
|
cc2d42f6d7 | ||
|
|
4faeb90dc0 | ||
|
|
9f7497d15c | ||
|
|
cab2f0a55e | ||
|
|
7704bffd88 | ||
|
|
3b421f60af | ||
|
|
86f4635080 | ||
|
|
f21757b9b3 | ||
|
|
9bb6d55687 | ||
|
|
bbb9c47cbb | ||
|
|
46aa53e726 | ||
|
|
4d320bc065 | ||
|
|
7b4e08859c | ||
|
|
d15b46825e | ||
|
|
e0ab5080ea | ||
|
|
6a8fbe1792 | ||
|
|
d867a6f0be | ||
|
|
248530c746 | ||
|
|
c73b71f328 | ||
|
|
d2c1e39a2e | ||
|
|
e86e7e103e | ||
|
|
42199024ff | ||
|
|
620fded2e1 | ||
|
|
90b7ed26b1 | ||
|
|
25c4d5f185 | ||
|
|
7a551ed384 | ||
|
|
ca0a47b160 | ||
|
|
6b069b55e3 | ||
|
|
2891b313d2 | ||
|
|
ede2c01eb7 | ||
|
|
6c05f0d594 | ||
|
|
15d714588f | ||
|
|
9a2ba08538 | ||
|
|
37084bec59 | ||
|
|
6910037e97 | ||
|
|
a72cdd85a4 | ||
|
|
31b4e73af5 | ||
|
|
088887c802 | ||
|
|
b7a9787cc3 | ||
|
|
e2e13f0f38 | ||
|
|
49ae335d7d | ||
|
|
2ba58a403f | ||
|
|
3de1619bf0 | ||
|
|
ec45067336 | ||
|
|
52fb65c5b1 | ||
|
|
478f369ad2 | ||
|
|
762c965377 | ||
|
|
d2b93310e2 | ||
|
|
f3b1e7f411 | ||
|
|
a55c6ccfdb | ||
|
|
b32433c336 | ||
|
|
6f11260cd1 | ||
|
|
ddd6aba091 | ||
|
|
e3f464c54e | ||
|
|
e86e58337a | ||
|
|
dbd47387be | ||
|
|
50e1276ab2 | ||
|
|
50c948ccfe | ||
|
|
949dad4fd2 | ||
|
|
01fd7dad6f | ||
|
|
fd408ba415 | ||
|
|
14e7ea2ec3 | ||
|
|
9f050aec67 | ||
|
|
9e118ddfaf | ||
|
|
157498b770 | ||
|
|
5c8a8f305d | ||
|
|
fc33bb60d0 | ||
|
|
498eb4d932 | ||
|
|
fb941cea99 | ||
|
|
a0af983bda | ||
|
|
4bd96e0718 | ||
|
|
7b0d3c2abb | ||
|
|
75d1551b00 | ||
|
|
406ba05bfb | ||
|
|
7a97d0aba5 | ||
|
|
b19c4c0c0c | ||
|
|
a466332822 | ||
|
|
e1fa01d123 | ||
|
|
37e41d3b76 | ||
|
|
2d5667d8ca | ||
|
|
22377c8f6a | ||
|
|
595895840a | ||
|
|
f6d647d5c8 | ||
|
|
1f9c60ffb2 | ||
|
|
6ec4a61c0c | ||
|
|
7feb088de3 | ||
|
|
627a821115 | ||
|
|
9b0ae98f02 | ||
|
|
85a7bba90e | ||
|
|
b18ef4c400 | ||
|
|
bbacdfc22a | ||
|
|
670f81c3dd | ||
|
|
b4f31dee23 | ||
|
|
dc6ec4fb4f | ||
|
|
07de415346 | ||
|
|
a15f64a234 | ||
|
|
0feaa3a0f7 | ||
|
|
a3527e1442 | ||
|
|
24565dc81f | ||
|
|
90655cded4 | ||
|
|
1e3b1997cc | ||
|
|
6f7d8a0987 | ||
|
|
55b6080273 | ||
|
|
ce9e2717d6 | ||
|
|
ef73895823 | ||
|
|
bcdec96d92 |
16
.github/workflows/build_cmake.yml
vendored
@@ -46,20 +46,18 @@ jobs:
|
||||
}
|
||||
qt_config:
|
||||
- {
|
||||
qt_version: "6.8.3",
|
||||
qt_creator_version: "16.0.2"
|
||||
qt_version: "6.10.1",
|
||||
qt_creator_version: "18.0.2"
|
||||
}
|
||||
- {
|
||||
qt_version: "6.9.2",
|
||||
qt_creator_version: "17.0.2"
|
||||
}
|
||||
- {
|
||||
qt_version: "6.10.0",
|
||||
qt_creator_version: "18.0.0"
|
||||
qt_version: "6.10.3",
|
||||
qt_creator_version: "19.0.2"
|
||||
}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Checkout submodules
|
||||
id: git
|
||||
@@ -165,7 +163,7 @@ jobs:
|
||||
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ../${archive} WORKING_DIRECTORY qt6)
|
||||
endfunction()
|
||||
|
||||
foreach(package qtbase qtdeclarative qttools)
|
||||
foreach(package qtbase qtdeclarative qttools qtsvg)
|
||||
downloadAndExtract(
|
||||
"${qt_base_url}/qt.qt6.${qt_version_dotless}.${qt_package_arch_suffix}/${qt_package_version}${package}${qt_package_suffix}.7z"
|
||||
${package}.7z
|
||||
|
||||
2
.gitignore
vendored
@@ -78,3 +78,5 @@ CMakeLists.txt.user*
|
||||
/.cursor
|
||||
/.vscode
|
||||
.qtc_clangd/compile_commands.json
|
||||
CLAUDE.md
|
||||
/.claude
|
||||
|
||||
3
.gitmodules
vendored
@@ -0,0 +1,3 @@
|
||||
[submodule "sources/external/llmqore"]
|
||||
path = sources/external/llmqore
|
||||
url = https://github.com/Palm1r/llmqore.git
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
list(APPEND CMAKE_PREFIX_PATH "/Users/palm1r/Qt/Qt Creator.sdk/lib/cmake/QtCreator")
|
||||
|
||||
project(QodeAssist)
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
@@ -11,10 +13,12 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
find_package(QtCreator REQUIRED COMPONENTS Core)
|
||||
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Test LinguistTools REQUIRED)
|
||||
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg Test LinguistTools REQUIRED)
|
||||
find_package(GTest)
|
||||
|
||||
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES en)
|
||||
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES
|
||||
en cs zh_CN zh_TW da de fr hr ja pl ru sl sv uk
|
||||
)
|
||||
|
||||
# IDE_VERSION is defined by QtCreator package
|
||||
string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" version_match ${IDE_VERSION})
|
||||
@@ -34,9 +38,9 @@ add_definitions(
|
||||
-DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
|
||||
)
|
||||
|
||||
add_subdirectory(llmcore)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(sources)
|
||||
add_subdirectory(logger)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(UIControls)
|
||||
add_subdirectory(ChatView)
|
||||
add_subdirectory(context)
|
||||
@@ -44,6 +48,11 @@ if(GTest_FOUND)
|
||||
add_subdirectory(test)
|
||||
endif()
|
||||
|
||||
option(QODEASSIST_BUILD_BENCH "Build the standalone agent bench CLI" ON)
|
||||
if(QODEASSIST_BUILD_BENCH)
|
||||
add_subdirectory(bench)
|
||||
endif()
|
||||
|
||||
add_qtc_plugin(QodeAssist
|
||||
PLUGIN_DEPENDS
|
||||
QtCreator::Core
|
||||
@@ -57,9 +66,14 @@ add_qtc_plugin(QodeAssist
|
||||
Qt::Quick
|
||||
Qt::Widgets
|
||||
Qt::Network
|
||||
Qt::Svg
|
||||
QtCreator::ExtensionSystem
|
||||
QtCreator::Utils
|
||||
QtCreator::CPlusPlus
|
||||
LLMQore
|
||||
ProvidersConfig
|
||||
Agents
|
||||
Skills
|
||||
QodeAssistChatViewplugin
|
||||
SOURCES
|
||||
.github/workflows/build_cmake.yml
|
||||
@@ -69,36 +83,7 @@ add_qtc_plugin(QodeAssist
|
||||
QodeAssistConstants.hpp
|
||||
QodeAssisttr.h
|
||||
LLMClientInterface.hpp LLMClientInterface.cpp
|
||||
templates/Templates.hpp
|
||||
templates/CodeLlamaFim.hpp
|
||||
templates/Ollama.hpp
|
||||
templates/Claude.hpp
|
||||
templates/OpenAI.hpp
|
||||
templates/MistralAI.hpp
|
||||
templates/StarCoder2Fim.hpp
|
||||
# templates/DeepSeekCoderFim.hpp
|
||||
# templates/CustomFimTemplate.hpp
|
||||
templates/Qwen25CoderFIM.hpp
|
||||
templates/OpenAICompatible.hpp
|
||||
templates/Llama3.hpp
|
||||
templates/ChatML.hpp
|
||||
templates/Alpaca.hpp
|
||||
templates/Llama2.hpp
|
||||
templates/CodeLlamaQMLFim.hpp
|
||||
templates/GoogleAI.hpp
|
||||
templates/LlamaCppFim.hpp
|
||||
templates/Qwen3CoderFIM.hpp
|
||||
providers/Providers.hpp
|
||||
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
|
||||
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
|
||||
providers/OpenAIProvider.hpp providers/OpenAIProvider.cpp
|
||||
providers/MistralAIProvider.hpp providers/MistralAIProvider.cpp
|
||||
providers/LMStudioProvider.hpp providers/LMStudioProvider.cpp
|
||||
providers/OpenAICompatProvider.hpp providers/OpenAICompatProvider.cpp
|
||||
providers/OpenRouterAIProvider.hpp providers/OpenRouterAIProvider.cpp
|
||||
providers/GoogleAIProvider.hpp providers/GoogleAIProvider.cpp
|
||||
providers/LlamaCppProvider.hpp providers/LlamaCppProvider.cpp
|
||||
providers/CodestralProvider.hpp providers/CodestralProvider.cpp
|
||||
RefactorContextHelper.hpp
|
||||
QodeAssist.qrc
|
||||
LSPCompletion.hpp
|
||||
LLMSuggestion.hpp LLMSuggestion.cpp
|
||||
@@ -107,11 +92,15 @@ add_qtc_plugin(QodeAssist
|
||||
QodeAssistClient.hpp QodeAssistClient.cpp
|
||||
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
||||
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
|
||||
ConfigurationManager.hpp ConfigurationManager.cpp
|
||||
chat/ChatDocument.hpp chat/ChatDocument.cpp
|
||||
chat/ChatEditor.hpp chat/ChatEditor.cpp
|
||||
chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp
|
||||
CodeHandler.hpp CodeHandler.cpp
|
||||
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
||||
widgets/CompletionProgressHandler.hpp widgets/CompletionProgressHandler.cpp
|
||||
widgets/CompletionErrorHandler.hpp widgets/CompletionErrorHandler.cpp
|
||||
widgets/CompletionHintWidget.hpp widgets/CompletionHintWidget.cpp
|
||||
widgets/CompletionHintHandler.hpp widgets/CompletionHintHandler.cpp
|
||||
widgets/ProgressWidget.hpp widgets/ProgressWidget.cpp
|
||||
widgets/ErrorWidget.hpp widgets/ErrorWidget.cpp
|
||||
widgets/EditorChatButton.hpp widgets/EditorChatButton.cpp
|
||||
@@ -119,26 +108,34 @@ add_qtc_plugin(QodeAssist
|
||||
widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp
|
||||
widgets/CustomInstructionsManager.hpp widgets/CustomInstructionsManager.cpp
|
||||
widgets/AddCustomInstructionDialog.hpp widgets/AddCustomInstructionDialog.cpp
|
||||
widgets/RefactorWidget.hpp widgets/RefactorWidget.cpp
|
||||
widgets/RefactorWidgetHandler.hpp widgets/RefactorWidgetHandler.cpp
|
||||
widgets/ContextExtractor.hpp
|
||||
widgets/DiffStatistics.hpp
|
||||
|
||||
QuickRefactorHandler.hpp QuickRefactorHandler.cpp
|
||||
tools/ToolsFactory.hpp tools/ToolsFactory.cpp
|
||||
tools/ReadVisibleFilesTool.hpp tools/ReadVisibleFilesTool.cpp
|
||||
tools/ToolHandler.hpp tools/ToolHandler.cpp
|
||||
tools/ToolsRegistration.hpp tools/ToolsRegistration.cpp
|
||||
tools/ListProjectFilesTool.hpp tools/ListProjectFilesTool.cpp
|
||||
tools/ToolsManager.hpp tools/ToolsManager.cpp
|
||||
tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp
|
||||
tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp
|
||||
tools/EditFileTool.hpp tools/EditFileTool.cpp
|
||||
tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp
|
||||
tools/ExecuteTerminalCommandTool.hpp tools/ExecuteTerminalCommandTool.cpp
|
||||
tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp
|
||||
tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp
|
||||
tools/FindFileTool.hpp tools/FindFileTool.cpp
|
||||
tools/ReadFileTool.hpp tools/ReadFileTool.cpp
|
||||
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
||||
providers/ClaudeMessage.hpp providers/ClaudeMessage.cpp
|
||||
providers/OpenAIMessage.hpp providers/OpenAIMessage.cpp
|
||||
providers/OllamaMessage.hpp providers/OllamaMessage.cpp
|
||||
providers/GoogleMessage.hpp providers/GoogleMessage.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
|
||||
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssist PRIVATE QodeAssistAgentPipelines Session)
|
||||
|
||||
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
|
||||
find_program(QtCreatorExecutable
|
||||
NAMES
|
||||
@@ -160,5 +157,5 @@ endif()
|
||||
qt_add_translations(TARGETS QodeAssist
|
||||
TS_FILE_DIR ${CMAKE_CURRENT_LIST_DIR}/resources/translations
|
||||
RESOURCE_PREFIX "/translations"
|
||||
LUPDATE_OPTIONS -no-obsolete
|
||||
LUPDATE_OPTIONS -no-obsolete -locations none
|
||||
)
|
||||
|
||||
@@ -10,18 +10,24 @@ qt_add_qml_module(QodeAssistChatView
|
||||
QtQuick
|
||||
QML_FILES
|
||||
qml/RootItem.qml
|
||||
qml/ChatItem.qml
|
||||
qml/dialog/CodeBlock.qml
|
||||
qml/dialog/TextBlock.qml
|
||||
qml/parts/TopBar.qml
|
||||
qml/parts/BottomBar.qml
|
||||
qml/parts/AttachedFilesPlace.qml
|
||||
qml/parts/Toast.qml
|
||||
qml/ToolStatusItem.qml
|
||||
qml/ThinkingStatusItem.qml
|
||||
qml/FileEditItem.qml
|
||||
qml/parts/RulesViewer.qml
|
||||
qml/parts/FileEditsActionBar.qml
|
||||
|
||||
qml/chatparts/CodeBlock.qml
|
||||
qml/chatparts/FileEditBlock.qml
|
||||
qml/chatparts/TextBlock.qml
|
||||
qml/chatparts/ThinkingBlock.qml
|
||||
qml/chatparts/ToolBlock.qml
|
||||
qml/chatparts/ChatItem.qml
|
||||
|
||||
qml/controls/AttachedFilesPlace.qml
|
||||
qml/controls/BottomBar.qml
|
||||
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
|
||||
qml/controls/MessageNavigator.qml
|
||||
|
||||
RESOURCES
|
||||
icons/attach-file-light.svg
|
||||
@@ -30,6 +36,7 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/close-light.svg
|
||||
icons/link-file-light.svg
|
||||
icons/link-file-dark.svg
|
||||
icons/image-dark.svg
|
||||
icons/load-chat-dark.svg
|
||||
icons/save-chat-dark.svg
|
||||
icons/clean-icon-dark.svg
|
||||
@@ -38,13 +45,22 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/window-unlock.svg
|
||||
icons/chat-icon.svg
|
||||
icons/chat-pause-icon.svg
|
||||
icons/warning-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
|
||||
icons/thinking-icon-on.svg
|
||||
icons/thinking-icon-off.svg
|
||||
icons/tools-icon-on.svg
|
||||
icons/tools-icon-off.svg
|
||||
icons/settings-icon.svg
|
||||
icons/compress-icon.svg
|
||||
icons/open-in-code.svg
|
||||
|
||||
SOURCES
|
||||
ChatWidget.hpp ChatWidget.cpp
|
||||
@@ -56,7 +72,15 @@ qt_add_qml_module(QodeAssistChatView
|
||||
ChatSerializer.hpp ChatSerializer.cpp
|
||||
ChatView.hpp ChatView.cpp
|
||||
ChatData.hpp
|
||||
|
||||
FileItem.hpp FileItem.cpp
|
||||
ChatFileManager.hpp ChatFileManager.cpp
|
||||
ChatCompressor.hpp ChatCompressor.cpp
|
||||
ChatAgentController.hpp ChatAgentController.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
|
||||
@@ -67,13 +91,16 @@ target_link_libraries(QodeAssistChatView
|
||||
Qt::Network
|
||||
QtCreator::Core
|
||||
QtCreator::Utils
|
||||
LLMCore
|
||||
QodeAssistSettings
|
||||
Context
|
||||
QodeAssistUIControlsplugin
|
||||
QodeAssistLogger
|
||||
LLMQore
|
||||
Skills
|
||||
Agents
|
||||
Session
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistChatView
|
||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
|
||||
105
ChatView/ChatAgentController.cpp
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ChatAgentController.hpp"
|
||||
|
||||
#include <QSettings>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentFactory.hpp>
|
||||
#include <sources/settings/PipelinesConfig.hpp>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
namespace {
|
||||
const char kChatAgentKey[] = "QodeAssist.chatActiveAgent";
|
||||
}
|
||||
|
||||
ChatAgentController::ChatAgentController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
if (auto *settings = Core::ICore::settings())
|
||||
m_currentAgent = settings->value(kChatAgentKey).toString();
|
||||
}
|
||||
|
||||
void ChatAgentController::setAgentFactory(AgentFactory *factory)
|
||||
{
|
||||
m_agentFactory = factory;
|
||||
reload();
|
||||
}
|
||||
|
||||
QStringList ChatAgentController::availableAgents() const
|
||||
{
|
||||
return m_availableAgents;
|
||||
}
|
||||
|
||||
QString ChatAgentController::currentAgent() const
|
||||
{
|
||||
return m_currentAgent;
|
||||
}
|
||||
|
||||
void ChatAgentController::setCurrentAgent(const QString &name)
|
||||
{
|
||||
if (name == m_currentAgent || !m_availableAgents.contains(name))
|
||||
return;
|
||||
|
||||
m_currentAgent = name;
|
||||
if (auto *settings = Core::ICore::settings())
|
||||
settings->setValue(kChatAgentKey, m_currentAgent);
|
||||
emit currentAgentChanged();
|
||||
}
|
||||
|
||||
void ChatAgentController::reload()
|
||||
{
|
||||
const QStringList all = m_agentFactory ? m_agentFactory->configNames() : QStringList{};
|
||||
const QStringList roster = Settings::PipelinesConfig::load().rosters.chatAssistant;
|
||||
|
||||
if (roster.isEmpty()) {
|
||||
m_availableAgents = all;
|
||||
} else {
|
||||
QStringList filtered;
|
||||
for (const QString &name : roster) {
|
||||
if (all.contains(name))
|
||||
filtered.append(name);
|
||||
}
|
||||
m_availableAgents = filtered.isEmpty() ? all : filtered;
|
||||
}
|
||||
|
||||
emit availableAgentsChanged();
|
||||
ensureValidCurrent();
|
||||
}
|
||||
|
||||
void ChatAgentController::ensureValidCurrent()
|
||||
{
|
||||
if (m_availableAgents.contains(m_currentAgent))
|
||||
return;
|
||||
|
||||
const QString next = m_availableAgents.isEmpty() ? QString() : m_availableAgents.first();
|
||||
if (next == m_currentAgent)
|
||||
return;
|
||||
|
||||
m_currentAgent = next;
|
||||
if (auto *settings = Core::ICore::settings())
|
||||
settings->setValue(kChatAgentKey, m_currentAgent);
|
||||
emit currentAgentChanged();
|
||||
}
|
||||
|
||||
bool ChatAgentController::currentSupportsThinking() const
|
||||
{
|
||||
if (!m_agentFactory || m_currentAgent.isEmpty())
|
||||
return false;
|
||||
const AgentConfig *config = m_agentFactory->configByName(m_currentAgent);
|
||||
return config && config->enableThinking;
|
||||
}
|
||||
|
||||
bool ChatAgentController::currentSupportsTools() const
|
||||
{
|
||||
if (!m_agentFactory || m_currentAgent.isEmpty())
|
||||
return false;
|
||||
const AgentConfig *config = m_agentFactory->configByName(m_currentAgent);
|
||||
return config && config->enableTools;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
47
ChatView/ChatAgentController.hpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatAgentController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatAgentController(QObject *parent = nullptr);
|
||||
|
||||
void setAgentFactory(AgentFactory *factory);
|
||||
|
||||
QStringList availableAgents() const;
|
||||
QString currentAgent() const;
|
||||
void setCurrentAgent(const QString &name);
|
||||
|
||||
bool currentSupportsThinking() const;
|
||||
bool currentSupportsTools() const;
|
||||
|
||||
void reload();
|
||||
|
||||
signals:
|
||||
void availableAgentsChanged();
|
||||
void currentAgentChanged();
|
||||
|
||||
private:
|
||||
void ensureValidCurrent();
|
||||
|
||||
QPointer<AgentFactory> m_agentFactory;
|
||||
QStringList m_availableAgents;
|
||||
QString m_currentAgent;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
271
ChatView/ChatCompressor.cpp
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatCompressor.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <Session.hpp>
|
||||
#include <SessionManager.hpp>
|
||||
#include <SystemPromptBuilder.hpp>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUuid>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatCompressor::ChatCompressor(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
void ChatCompressor::setSessionManager(SessionManager *sessionManager)
|
||||
{
|
||||
m_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
void ChatCompressor::setActiveAgent(const QString &agentName)
|
||||
{
|
||||
m_activeAgent = agentName;
|
||||
}
|
||||
|
||||
void ChatCompressor::startCompression(
|
||||
const QString &chatFilePath, ConversationHistory *sourceHistory)
|
||||
{
|
||||
if (m_isCompressing) {
|
||||
emit compressionFailed(tr("Compression already in progress"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatFilePath.isEmpty()) {
|
||||
emit compressionFailed(tr("No chat file to compress"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceHistory || sourceHistory->isEmpty()) {
|
||||
emit compressionFailed(tr("Chat is empty, nothing to compress"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_sessionManager) {
|
||||
emit compressionFailed(tr("Chat session manager is not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString sessionError;
|
||||
Session *session = m_sessionManager->acquire(m_activeAgent, &sessionError);
|
||||
if (!session) {
|
||||
emit compressionFailed(
|
||||
sessionError.isEmpty() ? tr("No chat agent selected") : sessionError);
|
||||
return;
|
||||
}
|
||||
|
||||
auto *client = session->client();
|
||||
if (!client) {
|
||||
m_sessionManager->removeSession(session);
|
||||
emit compressionFailed(tr("Chat agent has no live client"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_isCompressing = true;
|
||||
m_originalChatPath = chatFilePath;
|
||||
m_session = session;
|
||||
|
||||
emit compressionStarted();
|
||||
|
||||
session->systemPrompt()->setLayer(
|
||||
QStringLiteral("compression"),
|
||||
QStringLiteral(
|
||||
"You are a helpful assistant that creates concise summaries of conversations. "
|
||||
"Your summaries preserve key information, technical details, and the flow of "
|
||||
"discussion."));
|
||||
|
||||
auto *history = session->history();
|
||||
for (const auto &msg : sourceHistory->messages()) {
|
||||
if (msg.role() != Message::Role::User && msg.role() != Message::Role::Assistant)
|
||||
continue;
|
||||
const QString text = msg.text();
|
||||
if (text.trimmed().isEmpty())
|
||||
continue;
|
||||
|
||||
Message apiMessage(msg.role());
|
||||
apiMessage.appendBlock(std::make_unique<LLMQore::TextContent>(text));
|
||||
history->append(std::move(apiMessage));
|
||||
}
|
||||
|
||||
connect(
|
||||
session, &Session::finished, this,
|
||||
[this](const LLMQore::RequestID &id, const QString &) { onCompressionFinished(id); });
|
||||
connect(
|
||||
session, &Session::failed, this,
|
||||
[this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) {
|
||||
onCompressionFailed(id, error.message);
|
||||
});
|
||||
|
||||
client->setTransferTimeout(
|
||||
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
|
||||
|
||||
std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
|
||||
blocks.push_back(std::make_unique<LLMQore::TextContent>(buildCompressionPrompt()));
|
||||
|
||||
m_currentRequestId = session->send(std::move(blocks), /*toolsOverride=*/false);
|
||||
if (m_currentRequestId.isEmpty()) {
|
||||
handleCompressionError(tr("Failed to start compression request: %1")
|
||||
.arg(session->lastError().message));
|
||||
return;
|
||||
}
|
||||
LOG_MESSAGE(QString("Starting compression request: %1").arg(m_currentRequestId));
|
||||
}
|
||||
|
||||
bool ChatCompressor::isCompressing() const
|
||||
{
|
||||
return m_isCompressing;
|
||||
}
|
||||
|
||||
void ChatCompressor::cancelCompression()
|
||||
{
|
||||
if (!m_isCompressing)
|
||||
return;
|
||||
|
||||
LOG_MESSAGE("Cancelling compression request");
|
||||
cleanupState();
|
||||
emit compressionFailed(tr("Compression cancelled"));
|
||||
}
|
||||
|
||||
void ChatCompressor::onCompressionFinished(const QString &requestId)
|
||||
{
|
||||
if (!m_isCompressing || requestId != m_currentRequestId)
|
||||
return;
|
||||
|
||||
QString summary;
|
||||
if (m_session) {
|
||||
if (auto *history = m_session->history(); history && !history->isEmpty())
|
||||
summary = history->messages().back().text();
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Received summary, length: %1 characters").arg(summary.length()));
|
||||
|
||||
const QString compressedPath = createCompressedChatPath(m_originalChatPath);
|
||||
const QString sourcePath = m_originalChatPath;
|
||||
|
||||
cleanupState();
|
||||
|
||||
if (!createCompressedChatFile(sourcePath, compressedPath, summary)) {
|
||||
emit compressionFailed(tr("Failed to save compressed chat"));
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Compression completed: %1").arg(compressedPath));
|
||||
emit compressionCompleted(compressedPath);
|
||||
}
|
||||
|
||||
void ChatCompressor::onCompressionFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
if (!m_isCompressing || requestId != m_currentRequestId)
|
||||
return;
|
||||
|
||||
LOG_MESSAGE(QString("Compression request failed: %1").arg(error));
|
||||
handleCompressionError(tr("Compression failed: %1").arg(error));
|
||||
}
|
||||
|
||||
void ChatCompressor::handleCompressionError(const QString &error)
|
||||
{
|
||||
cleanupState();
|
||||
emit compressionFailed(error);
|
||||
}
|
||||
|
||||
QString ChatCompressor::createCompressedChatPath(const QString &originalPath) const
|
||||
{
|
||||
QFileInfo fileInfo(originalPath);
|
||||
QString hash = QString::number(QDateTime::currentMSecsSinceEpoch() % 100000, 16);
|
||||
return QString("%1/%2_%3.%4")
|
||||
.arg(fileInfo.absolutePath(), fileInfo.completeBaseName(), hash, fileInfo.suffix());
|
||||
}
|
||||
|
||||
QString ChatCompressor::buildCompressionPrompt() const
|
||||
{
|
||||
return QStringLiteral(
|
||||
"Please create a comprehensive summary of our entire conversation above. "
|
||||
"The summary should:\n"
|
||||
"1. Preserve all important context, decisions, and key information\n"
|
||||
"2. Maintain technical details, code snippets, file references, and specific examples\n"
|
||||
"3. Keep the chronological flow of the discussion\n"
|
||||
"4. Be significantly shorter than the original (aim for 30-40% of original length)\n"
|
||||
"5. Be written in clear, structured format\n"
|
||||
"6. Use markdown formatting for better readability\n\n"
|
||||
"Create the summary now:");
|
||||
}
|
||||
|
||||
bool ChatCompressor::createCompressedChatFile(
|
||||
const QString &sourcePath, const QString &destPath, const QString &summary)
|
||||
{
|
||||
QFile sourceFile(sourcePath);
|
||||
if (!sourceFile.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open source chat file: %1").arg(sourcePath));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(sourceFile.readAll(), &parseError);
|
||||
sourceFile.close();
|
||||
|
||||
if (doc.isNull() || !doc.isObject()) {
|
||||
LOG_MESSAGE(QString("Invalid JSON in chat file: %1 (Error: %2)")
|
||||
.arg(sourcePath, parseError.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject root = doc.object();
|
||||
|
||||
QJsonObject summaryMessage;
|
||||
summaryMessage["role"] = "assistant";
|
||||
summaryMessage["id"] = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
QJsonObject textBlock;
|
||||
textBlock["type"] = "text";
|
||||
textBlock["text"] = QString("# Chat Summary\n\n%1").arg(summary);
|
||||
summaryMessage["blocks"] = QJsonArray{textBlock};
|
||||
|
||||
root["messages"] = QJsonArray{summaryMessage};
|
||||
root["compressedFrom"] = sourcePath;
|
||||
root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||
|
||||
if (QFile::exists(destPath))
|
||||
QFile::remove(destPath);
|
||||
|
||||
QFile destFile(destPath);
|
||||
if (!destFile.open(QIODevice::WriteOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to create compressed chat file: %1").arg(destPath));
|
||||
return false;
|
||||
}
|
||||
|
||||
destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatCompressor::cleanupState()
|
||||
{
|
||||
Session *session = m_session;
|
||||
|
||||
m_isCompressing = false;
|
||||
m_currentRequestId.clear();
|
||||
m_originalChatPath.clear();
|
||||
m_session = nullptr;
|
||||
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->release(session);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
59
ChatView/ChatCompressor.hpp
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist {
|
||||
class SessionManager;
|
||||
class Session;
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatCompressor : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatCompressor(QObject *parent = nullptr);
|
||||
|
||||
void setSessionManager(SessionManager *sessionManager);
|
||||
void setActiveAgent(const QString &agentName);
|
||||
|
||||
void startCompression(const QString &chatFilePath, ConversationHistory *sourceHistory);
|
||||
|
||||
bool isCompressing() const;
|
||||
void cancelCompression();
|
||||
|
||||
signals:
|
||||
void compressionStarted();
|
||||
void compressionCompleted(const QString &compressedChatPath);
|
||||
void compressionFailed(const QString &error);
|
||||
|
||||
private:
|
||||
void onCompressionFinished(const QString &requestId);
|
||||
void onCompressionFailed(const QString &requestId, const QString &error);
|
||||
|
||||
QString createCompressedChatPath(const QString &originalPath) const;
|
||||
QString buildCompressionPrompt() const;
|
||||
bool createCompressedChatFile(
|
||||
const QString &sourcePath, const QString &destPath, const QString &summary);
|
||||
void cleanupState();
|
||||
void handleCompressionError(const QString &error);
|
||||
|
||||
bool m_isCompressing = false;
|
||||
QString m_currentRequestId;
|
||||
QString m_originalChatPath;
|
||||
QPointer<SessionManager> m_sessionManager;
|
||||
QString m_activeAgent;
|
||||
QPointer<Session> m_session;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -26,7 +11,7 @@ namespace QodeAssist::Chat {
|
||||
Q_NAMESPACE
|
||||
QML_NAMED_ELEMENT(MessagePartType)
|
||||
|
||||
enum class MessagePartType { Code, Text };
|
||||
enum class MessagePartType { Code, Text, Image };
|
||||
Q_ENUM_NS(MessagePartType)
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
191
ChatView/ChatFileManager.cpp
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatFileManager.hpp"
|
||||
#include "Logger.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QStandardPaths>
|
||||
#include <QUuid>
|
||||
#include <QDateTime>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatFileManager::ChatFileManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_intermediateStorageDir(getIntermediateStorageDir())
|
||||
{}
|
||||
|
||||
ChatFileManager::~ChatFileManager() = default;
|
||||
|
||||
QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
|
||||
{
|
||||
QStringList processedPaths;
|
||||
processedPaths.reserve(filePaths.size());
|
||||
|
||||
for (const QString &filePath : filePaths) {
|
||||
if (!isFileAccessible(filePath)) {
|
||||
const QString error = tr("File is not accessible: %1").arg(filePath);
|
||||
LOG_MESSAGE(error);
|
||||
emit fileOperationFailed(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
QString copiedPath = copyToIntermediateStorage(filePath);
|
||||
if (!copiedPath.isEmpty()) {
|
||||
processedPaths.append(copiedPath);
|
||||
emit fileCopiedToStorage(filePath, copiedPath);
|
||||
LOG_MESSAGE(QString("File copied to storage: %1 -> %2").arg(filePath, copiedPath));
|
||||
} else {
|
||||
const QString error = tr("Failed to copy file: %1").arg(filePath);
|
||||
LOG_MESSAGE(error);
|
||||
emit fileOperationFailed(error);
|
||||
}
|
||||
}
|
||||
|
||||
return processedPaths;
|
||||
}
|
||||
|
||||
void ChatFileManager::setChatFilePath(const QString &chatFilePath)
|
||||
{
|
||||
m_chatFilePath = chatFilePath;
|
||||
}
|
||||
|
||||
QString ChatFileManager::chatFilePath() const
|
||||
{
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
void ChatFileManager::clearIntermediateStorage()
|
||||
{
|
||||
QDir dir(m_intermediateStorageDir);
|
||||
if (!dir.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
|
||||
for (const QFileInfo &fileInfo : files) {
|
||||
QFile file(fileInfo.absoluteFilePath());
|
||||
file.setPermissions(QFile::WriteUser | QFile::ReadUser);
|
||||
if (file.remove()) {
|
||||
LOG_MESSAGE(QString("Removed intermediate file: %1").arg(fileInfo.fileName()));
|
||||
} else {
|
||||
LOG_MESSAGE(QString("Failed to remove intermediate file: %1")
|
||||
.arg(fileInfo.fileName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatFileManager::isFileAccessible(const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable();
|
||||
}
|
||||
|
||||
void ChatFileManager::cleanupGlobalIntermediateStorage()
|
||||
{
|
||||
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
|
||||
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
|
||||
|
||||
QDir dir(intermediatePath);
|
||||
if (!dir.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
|
||||
int removedCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
for (const QFileInfo &fileInfo : files) {
|
||||
QFile file(fileInfo.absoluteFilePath());
|
||||
file.setPermissions(QFile::WriteUser | QFile::ReadUser);
|
||||
if (file.remove()) {
|
||||
removedCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0 || failedCount > 0) {
|
||||
LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2")
|
||||
.arg(removedCount)
|
||||
.arg(failedCount));
|
||||
}
|
||||
}
|
||||
|
||||
QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
if (!fileInfo.exists() || !fileInfo.isFile()) {
|
||||
LOG_MESSAGE(QString("Source file does not exist or is not a file: %1").arg(filePath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
if (fileInfo.size() == 0) {
|
||||
LOG_MESSAGE(QString("Source file is empty: %1").arg(filePath));
|
||||
}
|
||||
|
||||
const QString newFileName = generateIntermediateFileName(filePath);
|
||||
const QString destinationPath = QDir(m_intermediateStorageDir).filePath(newFileName);
|
||||
|
||||
if (QFileInfo::exists(destinationPath)) {
|
||||
QFile::remove(destinationPath);
|
||||
}
|
||||
|
||||
if (!QFile::copy(filePath, destinationPath)) {
|
||||
LOG_MESSAGE(QString("Failed to copy file: %1 -> %2").arg(filePath, destinationPath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QFile copiedFile(destinationPath);
|
||||
if (!copiedFile.exists()) {
|
||||
LOG_MESSAGE(QString("Copied file does not exist after copy: %1").arg(destinationPath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
copiedFile.setPermissions(QFile::ReadUser | QFile::WriteUser);
|
||||
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
QString ChatFileManager::getIntermediateStorageDir()
|
||||
{
|
||||
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
|
||||
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
|
||||
|
||||
QDir dir;
|
||||
if (!dir.exists(intermediatePath) && !dir.mkpath(intermediatePath)) {
|
||||
LOG_MESSAGE(QString("Failed to create intermediate storage directory: %1")
|
||||
.arg(intermediatePath));
|
||||
}
|
||||
|
||||
return intermediatePath;
|
||||
}
|
||||
|
||||
QString ChatFileManager::generateIntermediateFileName(const QString &originalPath)
|
||||
{
|
||||
const QFileInfo fileInfo(originalPath);
|
||||
const QString extension = fileInfo.suffix();
|
||||
QString baseName = fileInfo.completeBaseName().left(30);
|
||||
|
||||
static const QRegularExpression specialChars("[^a-zA-Z0-9_-]");
|
||||
baseName.replace(specialChars, "_");
|
||||
|
||||
if (baseName.isEmpty()) {
|
||||
baseName = "file";
|
||||
}
|
||||
|
||||
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
||||
const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8);
|
||||
|
||||
return QString("%1_%2_%3.%4").arg(baseName, timestamp, uuid, extension);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
44
ChatView/ChatFileManager.hpp
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QMap>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatFileManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatFileManager(QObject *parent = nullptr);
|
||||
~ChatFileManager();
|
||||
|
||||
QStringList processDroppedFiles(const QStringList &filePaths);
|
||||
void setChatFilePath(const QString &chatFilePath);
|
||||
QString chatFilePath() const;
|
||||
void clearIntermediateStorage();
|
||||
|
||||
static bool isFileAccessible(const QString &filePath);
|
||||
static void cleanupGlobalIntermediateStorage();
|
||||
|
||||
signals:
|
||||
void fileOperationFailed(const QString &error);
|
||||
void fileCopiedToStorage(const QString &originalPath, const QString &newPath);
|
||||
|
||||
private:
|
||||
QString copyToIntermediateStorage(const QString &filePath);
|
||||
QString getIntermediateStorageDir();
|
||||
QString generateIntermediateFileName(const QString &originalPath);
|
||||
|
||||
QString m_chatFilePath;
|
||||
QString m_intermediateStorageDir;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
240
ChatView/ChatHistoryStore.cpp
Normal file
@@ -0,0 +1,240 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "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 <ConversationHistory.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatHistoryStore::ChatHistoryStore(ConversationHistory *history, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_history(history)
|
||||
{}
|
||||
|
||||
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_history) {
|
||||
for (const auto &message : m_history->messages()) {
|
||||
if (message.role() != Message::Role::User)
|
||||
continue;
|
||||
|
||||
const QString text = message.text();
|
||||
if (!text.trimmed().isEmpty()) {
|
||||
shortMessage = text.split('\n').first().simplified().left(30);
|
||||
} else {
|
||||
for (const auto &block : message.blocks()) {
|
||||
if (dynamic_cast<StoredImageContent *>(block.get())) {
|
||||
shortMessage = "image_chat";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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_history, filePath);
|
||||
}
|
||||
|
||||
SerializationResult ChatHistoryStore::load(const QString &filePath) const
|
||||
{
|
||||
return ChatSerializer::loadFromFile(m_history, 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
|
||||
50
ChatView/ChatHistoryStore.hpp
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
|
||||
namespace QodeAssist {
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatHistoryStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatHistoryStore(ConversationHistory *history, 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;
|
||||
|
||||
ConversationHistory *m_history;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,88 +1,161 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include <utils/aspects.h>
|
||||
#include <QDateTime>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QtQml>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrl>
|
||||
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
|
||||
#include "context/ChangesManager.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kFileEditMarker = QStringLiteral("QODEASSIST_FILE_EDIT:");
|
||||
|
||||
QString changesStatusToString(Context::ChangesManager::FileEditStatus status)
|
||||
{
|
||||
switch (status) {
|
||||
case Context::ChangesManager::Pending: return QStringLiteral("pending");
|
||||
case Context::ChangesManager::Applied: return QStringLiteral("applied");
|
||||
case Context::ChangesManager::Rejected: return QStringLiteral("rejected");
|
||||
case Context::ChangesManager::Archived: return QStringLiteral("archived");
|
||||
}
|
||||
return QStringLiteral("pending");
|
||||
}
|
||||
|
||||
QString parseEditId(const QString &markerContent)
|
||||
{
|
||||
const int pos = markerContent.indexOf(kFileEditMarker);
|
||||
if (pos < 0)
|
||||
return {};
|
||||
const QString jsonStr = markerContent.mid(pos + kFileEditMarker.length());
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
|
||||
if (!doc.isObject())
|
||||
return {};
|
||||
return doc.object().value(QStringLiteral("edit_id")).toString();
|
||||
}
|
||||
|
||||
QString collectText(const Message &m)
|
||||
{
|
||||
QString text;
|
||||
for (const auto &block : m.blocks()) {
|
||||
if (auto *t = dynamic_cast<LLMQore::TextContent *>(block.get())) {
|
||||
if (!text.isEmpty())
|
||||
text += QLatin1Char('\n');
|
||||
text += t->text();
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
bool messageIsToolResultsOnly(const Message &m)
|
||||
{
|
||||
bool hasToolResult = false;
|
||||
for (const auto &block : m.blocks()) {
|
||||
if (dynamic_cast<LLMQore::ToolResultContent *>(block.get()))
|
||||
hasToolResult = true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return hasToolResult;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatModel::ChatModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
|
||||
auto &changes = Context::ChangesManager::instance();
|
||||
connect(
|
||||
&settings.chatTokensThreshold,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&ChatModel::tokensThresholdChanged);
|
||||
|
||||
connect(&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditApplied,
|
||||
this,
|
||||
&ChatModel::onFileEditApplied);
|
||||
|
||||
connect(&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditRejected,
|
||||
this,
|
||||
&ChatModel::onFileEditRejected);
|
||||
|
||||
connect(&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditArchived,
|
||||
this,
|
||||
&ChatModel::onFileEditArchived);
|
||||
&changes, &Context::ChangesManager::fileEditApplied,
|
||||
this, &ChatModel::onFileEditStatusChanged);
|
||||
connect(
|
||||
&changes, &Context::ChangesManager::fileEditRejected,
|
||||
this, &ChatModel::onFileEditStatusChanged);
|
||||
connect(
|
||||
&changes, &Context::ChangesManager::fileEditUndone,
|
||||
this, &ChatModel::onFileEditStatusChanged);
|
||||
connect(
|
||||
&changes, &Context::ChangesManager::fileEditArchived,
|
||||
this, &ChatModel::onFileEditStatusChanged);
|
||||
}
|
||||
|
||||
void ChatModel::setHistory(ConversationHistory *history)
|
||||
{
|
||||
if (m_history == history)
|
||||
return;
|
||||
|
||||
if (m_history)
|
||||
m_history->disconnect(this);
|
||||
|
||||
m_history = history;
|
||||
|
||||
if (m_history) {
|
||||
connect(
|
||||
m_history, &ConversationHistory::messageAdded,
|
||||
this, &ChatModel::onHistoryMessageAdded);
|
||||
connect(
|
||||
m_history, &ConversationHistory::messageUpdated,
|
||||
this, &ChatModel::onHistoryMessageUpdated);
|
||||
connect(m_history, &ConversationHistory::cleared, this, &ChatModel::onHistoryCleared);
|
||||
connect(m_history, &ConversationHistory::reset, this, &ChatModel::onHistoryReset);
|
||||
}
|
||||
|
||||
beginResetModel();
|
||||
rebuildAll();
|
||||
endResetModel();
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
int ChatModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
return m_messages.size();
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
return m_rows.size();
|
||||
}
|
||||
|
||||
QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() >= m_messages.size())
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size())
|
||||
return QVariant();
|
||||
|
||||
const Message &message = m_messages[index.row()];
|
||||
const Row &row = m_rows[index.row()];
|
||||
switch (static_cast<Roles>(role)) {
|
||||
case Roles::RoleType:
|
||||
return QVariant::fromValue(message.role);
|
||||
case Roles::Content: {
|
||||
return message.content;
|
||||
}
|
||||
case Roles::Attachments: {
|
||||
QStringList filenames;
|
||||
for (const auto &attachment : message.attachments) {
|
||||
filenames << attachment.filename;
|
||||
}
|
||||
return filenames;
|
||||
}
|
||||
case Roles::IsRedacted: {
|
||||
return message.isRedacted;
|
||||
return QVariant::fromValue(row.kind);
|
||||
case Roles::Content:
|
||||
if (row.kind == ChatRole::FileEdit)
|
||||
return overlayFileEditStatus(row.content, row.editId);
|
||||
return row.content;
|
||||
case Roles::Attachments:
|
||||
return buildAttachmentList(row.attachments);
|
||||
case Roles::Images:
|
||||
return buildImageList(row.images);
|
||||
case Roles::IsRedacted:
|
||||
return row.isRedacted;
|
||||
case Roles::PromptTokens:
|
||||
return m_usageByMessageId.value(row.messageId).prompt;
|
||||
case Roles::CompletionTokens:
|
||||
return m_usageByMessageId.value(row.messageId).completion;
|
||||
case Roles::CachedPromptTokens:
|
||||
return m_usageByMessageId.value(row.messageId).cached;
|
||||
case Roles::ReasoningTokens:
|
||||
return m_usageByMessageId.value(row.messageId).reasoning;
|
||||
case Roles::TotalTokens: {
|
||||
const Usage u = m_usageByMessageId.value(row.messageId);
|
||||
return u.prompt + u.completion;
|
||||
}
|
||||
default:
|
||||
return QVariant();
|
||||
@@ -96,89 +169,320 @@ QHash<int, QByteArray> ChatModel::roleNames() const
|
||||
roles[Roles::Content] = "content";
|
||||
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;
|
||||
}
|
||||
|
||||
void ChatModel::addMessage(
|
||||
const QString &content,
|
||||
ChatRole role,
|
||||
const QString &id,
|
||||
const QList<Context::ContentFile> &attachments)
|
||||
QVariantList ChatModel::buildAttachmentList(const QVector<AttachmentRef> &attachments) const
|
||||
{
|
||||
QString fullContent = content;
|
||||
if (!attachments.isEmpty()) {
|
||||
fullContent += "\n\nAttached files list:";
|
||||
for (const auto &attachment : attachments) {
|
||||
fullContent += QString("\nname: %1\nfile content:\n%2")
|
||||
.arg(attachment.filename, attachment.content);
|
||||
QVariantList list;
|
||||
for (const auto &attachment : attachments) {
|
||||
QVariantMap map;
|
||||
map["fileName"] = attachment.fileName;
|
||||
map["storedPath"] = attachment.storedPath;
|
||||
if (!m_chatFilePath.isEmpty()) {
|
||||
QFileInfo fileInfo(m_chatFilePath);
|
||||
const QString contentFolder
|
||||
= QDir(fileInfo.absolutePath()).filePath(fileInfo.completeBaseName() + "_content");
|
||||
map["filePath"] = QDir(contentFolder).filePath(attachment.storedPath);
|
||||
} else {
|
||||
map["filePath"] = QString();
|
||||
}
|
||||
list.append(map);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
QVariantList ChatModel::buildImageList(const QVector<ImageRef> &images) const
|
||||
{
|
||||
QVariantList list;
|
||||
for (const auto &image : images) {
|
||||
QVariantMap map;
|
||||
map["fileName"] = image.fileName;
|
||||
map["storedPath"] = image.storedPath;
|
||||
map["mediaType"] = image.mediaType;
|
||||
if (!m_chatFilePath.isEmpty()) {
|
||||
QFileInfo fileInfo(m_chatFilePath);
|
||||
const QString contentFolder
|
||||
= QDir(fileInfo.absolutePath()).filePath(fileInfo.completeBaseName() + "_content");
|
||||
const QString fullPath = QDir(contentFolder).filePath(image.storedPath);
|
||||
map["imageUrl"] = QUrl::fromLocalFile(fullPath).toString();
|
||||
map["filePath"] = fullPath;
|
||||
} else {
|
||||
map["imageUrl"] = QString();
|
||||
map["filePath"] = QString();
|
||||
}
|
||||
list.append(map);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
QString ChatModel::overlayFileEditStatus(const QString &content, const QString &editId) const
|
||||
{
|
||||
const int pos = content.indexOf(kFileEditMarker);
|
||||
if (pos < 0)
|
||||
return content;
|
||||
|
||||
const QString jsonStr = content.mid(pos + kFileEditMarker.length());
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
|
||||
if (!doc.isObject())
|
||||
return content;
|
||||
|
||||
QJsonObject obj = doc.object();
|
||||
if (!editId.isEmpty()) {
|
||||
const auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
if (!edit.editId.isEmpty()) {
|
||||
obj["status"] = changesStatusToString(edit.status);
|
||||
if (!edit.statusMessage.isEmpty())
|
||||
obj["status_message"] = edit.statusMessage;
|
||||
}
|
||||
}
|
||||
return kFileEditMarker
|
||||
+ QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id
|
||||
&& m_messages.last().role == role) {
|
||||
Message &lastMessage = m_messages.last();
|
||||
lastMessage.content = content;
|
||||
lastMessage.attachments = attachments;
|
||||
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
|
||||
} else {
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message newMessage{role, content, id};
|
||||
newMessage.attachments = attachments;
|
||||
m_messages.append(newMessage);
|
||||
endInsertRows();
|
||||
|
||||
if (m_loadingFromHistory && role == ChatRole::FileEdit) {
|
||||
const QString marker = "QODEASSIST_FILE_EDIT:";
|
||||
if (content.contains(marker)) {
|
||||
int markerPos = content.indexOf(marker);
|
||||
int jsonStart = markerPos + marker.length();
|
||||
|
||||
if (jsonStart < content.length()) {
|
||||
QString jsonStr = content.mid(jsonStart);
|
||||
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
|
||||
|
||||
if (doc.isObject()) {
|
||||
QJsonObject editData = doc.object();
|
||||
QString editId = editData.value("edit_id").toString();
|
||||
QString filePath = editData.value("file").toString();
|
||||
QString oldContent = editData.value("old_content").toString();
|
||||
QString newContent = editData.value("new_content").toString();
|
||||
QString originalStatus = editData.value("status").toString();
|
||||
|
||||
if (!editId.isEmpty() && !filePath.isEmpty()) {
|
||||
Context::ChangesManager::instance().addFileEdit(
|
||||
editId, filePath, oldContent, newContent, false, true);
|
||||
|
||||
editData["status"] = "archived";
|
||||
editData["status_message"] = "Loaded from chat history";
|
||||
|
||||
QString updatedContent = marker
|
||||
+ QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact));
|
||||
m_messages.last().content = updatedContent;
|
||||
|
||||
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
|
||||
|
||||
LOG_MESSAGE(QString("Registered historical file edit: %1 (original status: %2, now: archived)")
|
||||
.arg(editId, originalStatus));
|
||||
}
|
||||
}
|
||||
QHash<QString, QString> ChatModel::buildToolResultMap() const
|
||||
{
|
||||
QHash<QString, QString> results;
|
||||
if (!m_history)
|
||||
return results;
|
||||
for (const auto &m : m_history->messages()) {
|
||||
for (const auto &block : m.blocks()) {
|
||||
if (auto *tr = dynamic_cast<LLMQore::ToolResultContent *>(block.get()))
|
||||
results.insert(tr->toolUseId(), tr->result());
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
void ChatModel::appendRowsForMessage(
|
||||
int messageIndex, const QHash<QString, QString> &toolResults, QVector<Row> &out) const
|
||||
{
|
||||
if (!m_history || messageIndex < 0 || messageIndex >= m_history->size())
|
||||
return;
|
||||
|
||||
const Message &m = m_history->messages()[static_cast<size_t>(messageIndex)];
|
||||
const QString id = m.id();
|
||||
|
||||
switch (m.role()) {
|
||||
case Message::Role::System: {
|
||||
const QString text = collectText(m);
|
||||
if (!text.trimmed().isEmpty()) {
|
||||
Row row;
|
||||
row.kind = ChatRole::System;
|
||||
row.messageIndex = messageIndex;
|
||||
row.messageId = id;
|
||||
row.content = text;
|
||||
out.append(std::move(row));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Message::Role::User: {
|
||||
QString text;
|
||||
QVector<AttachmentRef> attachments;
|
||||
QVector<ImageRef> images;
|
||||
bool hasDisplayable = false;
|
||||
for (const auto &block : m.blocks()) {
|
||||
if (auto *t = dynamic_cast<LLMQore::TextContent *>(block.get())) {
|
||||
if (!text.isEmpty())
|
||||
text += QLatin1Char('\n');
|
||||
text += t->text();
|
||||
hasDisplayable = true;
|
||||
} else if (auto *sa = dynamic_cast<StoredAttachmentContent *>(block.get())) {
|
||||
attachments.append({sa->fileName(), sa->storedPath()});
|
||||
hasDisplayable = true;
|
||||
} else if (auto *si = dynamic_cast<StoredImageContent *>(block.get())) {
|
||||
images.append({si->fileName(), si->storedPath(), si->mediaType()});
|
||||
hasDisplayable = true;
|
||||
}
|
||||
}
|
||||
if (hasDisplayable) {
|
||||
Row row;
|
||||
row.kind = ChatRole::User;
|
||||
row.messageIndex = messageIndex;
|
||||
row.messageId = id;
|
||||
row.content = text;
|
||||
row.attachments = std::move(attachments);
|
||||
row.images = std::move(images);
|
||||
out.append(std::move(row));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Message::Role::Assistant: {
|
||||
for (const auto &block : m.blocks()) {
|
||||
if (auto *th = dynamic_cast<LLMQore::ThinkingContent *>(block.get())) {
|
||||
QString content = th->thinking();
|
||||
if (!th->signature().isEmpty())
|
||||
content += QStringLiteral("\n[Signature: ") + th->signature().left(40)
|
||||
+ QStringLiteral("...]");
|
||||
Row row;
|
||||
row.kind = ChatRole::Thinking;
|
||||
row.messageIndex = messageIndex;
|
||||
row.messageId = id;
|
||||
row.content = content;
|
||||
out.append(std::move(row));
|
||||
} else if (
|
||||
auto *rth = dynamic_cast<LLMQore::RedactedThinkingContent *>(block.get())) {
|
||||
QString content = QStringLiteral("[Thinking content redacted by safety systems]");
|
||||
if (!rth->signature().isEmpty())
|
||||
content += QStringLiteral("\n[Signature: ") + rth->signature().left(40)
|
||||
+ QStringLiteral("...]");
|
||||
Row row;
|
||||
row.kind = ChatRole::Thinking;
|
||||
row.messageIndex = messageIndex;
|
||||
row.messageId = id;
|
||||
row.content = content;
|
||||
row.isRedacted = true;
|
||||
out.append(std::move(row));
|
||||
} else if (auto *t = dynamic_cast<LLMQore::TextContent *>(block.get())) {
|
||||
if (!t->text().trimmed().isEmpty()) {
|
||||
Row row;
|
||||
row.kind = ChatRole::Assistant;
|
||||
row.messageIndex = messageIndex;
|
||||
row.messageId = id;
|
||||
row.content = t->text();
|
||||
out.append(std::move(row));
|
||||
}
|
||||
} else if (auto *tu = dynamic_cast<LLMQore::ToolUseContent *>(block.get())) {
|
||||
const QString result = toolResults.value(tu->id());
|
||||
Row toolRow;
|
||||
toolRow.kind = ChatRole::Tool;
|
||||
toolRow.messageIndex = messageIndex;
|
||||
toolRow.messageId = id;
|
||||
toolRow.content = tu->name() + QLatin1Char('\n') + result;
|
||||
out.append(std::move(toolRow));
|
||||
|
||||
if (result.contains(kFileEditMarker)) {
|
||||
Row editRow;
|
||||
editRow.kind = ChatRole::FileEdit;
|
||||
editRow.messageIndex = messageIndex;
|
||||
editRow.messageId = id;
|
||||
editRow.content = result;
|
||||
editRow.editId = parseEditId(result);
|
||||
out.append(std::move(editRow));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QVector<ChatModel::Message> ChatModel::getChatHistory() const
|
||||
void ChatModel::rebuildAll()
|
||||
{
|
||||
return m_messages;
|
||||
m_rows.clear();
|
||||
if (!m_history)
|
||||
return;
|
||||
const QHash<QString, QString> toolResults = buildToolResultMap();
|
||||
for (int mi = 0; mi < m_history->size(); ++mi)
|
||||
appendRowsForMessage(mi, toolResults, m_rows);
|
||||
}
|
||||
|
||||
int ChatModel::firstRowForMessage(int messageIndex) const
|
||||
{
|
||||
for (int i = 0; i < m_rows.size(); ++i) {
|
||||
if (m_rows[i].messageIndex >= messageIndex)
|
||||
return i;
|
||||
}
|
||||
return m_rows.size();
|
||||
}
|
||||
|
||||
int ChatModel::startMessageIndexFor(int messageIndex) const
|
||||
{
|
||||
if (!m_history || messageIndex < 0 || messageIndex >= m_history->size())
|
||||
return messageIndex;
|
||||
|
||||
const auto &msgs = m_history->messages();
|
||||
const Message &m = msgs[static_cast<size_t>(messageIndex)];
|
||||
if (m.role() == Message::Role::User && messageIsToolResultsOnly(m)) {
|
||||
for (int j = messageIndex - 1; j >= 0; --j) {
|
||||
if (msgs[static_cast<size_t>(j)].role() == Message::Role::Assistant)
|
||||
return j;
|
||||
}
|
||||
}
|
||||
return messageIndex;
|
||||
}
|
||||
|
||||
void ChatModel::reprojectTail(int startMessageIndex)
|
||||
{
|
||||
if (!m_history)
|
||||
return;
|
||||
|
||||
const int oldStart = firstRowForMessage(startMessageIndex);
|
||||
const QHash<QString, QString> toolResults = buildToolResultMap();
|
||||
|
||||
QVector<Row> newTail;
|
||||
for (int mi = startMessageIndex; mi < m_history->size(); ++mi)
|
||||
appendRowsForMessage(mi, toolResults, newTail);
|
||||
|
||||
const int oldCount = m_rows.size() - oldStart;
|
||||
const int newCount = newTail.size();
|
||||
const int common = qMin(oldCount, newCount);
|
||||
|
||||
for (int i = 0; i < common; ++i)
|
||||
m_rows[oldStart + i] = newTail[i];
|
||||
if (common > 0)
|
||||
emit dataChanged(index(oldStart), index(oldStart + common - 1));
|
||||
|
||||
if (newCount > oldCount) {
|
||||
beginInsertRows(QModelIndex(), oldStart + oldCount, oldStart + newCount - 1);
|
||||
for (int i = oldCount; i < newCount; ++i)
|
||||
m_rows.append(newTail[i]);
|
||||
endInsertRows();
|
||||
} else if (newCount < oldCount) {
|
||||
beginRemoveRows(QModelIndex(), oldStart + newCount, oldStart + oldCount - 1);
|
||||
m_rows.remove(oldStart + newCount, oldCount - newCount);
|
||||
endRemoveRows();
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::onHistoryMessageAdded(int index)
|
||||
{
|
||||
reprojectTail(startMessageIndexFor(index));
|
||||
}
|
||||
|
||||
void ChatModel::onHistoryMessageUpdated(int index)
|
||||
{
|
||||
reprojectTail(startMessageIndexFor(index));
|
||||
}
|
||||
|
||||
void ChatModel::onHistoryCleared()
|
||||
{
|
||||
beginResetModel();
|
||||
m_rows.clear();
|
||||
m_usageByMessageId.clear();
|
||||
endResetModel();
|
||||
emit modelReseted();
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
void ChatModel::onHistoryReset()
|
||||
{
|
||||
beginResetModel();
|
||||
rebuildAll();
|
||||
endResetModel();
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
void ChatModel::onFileEditStatusChanged(const QString &editId)
|
||||
{
|
||||
for (int i = 0; i < m_rows.size(); ++i) {
|
||||
if (m_rows[i].kind == ChatRole::FileEdit && m_rows[i].editId == editId)
|
||||
emit dataChanged(index(i), index(i), {Roles::Content});
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::clear()
|
||||
{
|
||||
beginResetModel();
|
||||
m_messages.clear();
|
||||
endResetModel();
|
||||
emit modelReseted();
|
||||
if (m_history)
|
||||
m_history->clear();
|
||||
else
|
||||
onHistoryCleared();
|
||||
}
|
||||
|
||||
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
|
||||
@@ -241,297 +545,106 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
|
||||
return parts;
|
||||
}
|
||||
|
||||
QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) const
|
||||
{
|
||||
QJsonArray messages;
|
||||
messages.append(QJsonObject{{"role", "system"}, {"content", systemPrompt}});
|
||||
|
||||
for (const auto &message : m_messages) {
|
||||
QString role;
|
||||
switch (message.role) {
|
||||
case ChatRole::User:
|
||||
role = "user";
|
||||
break;
|
||||
case ChatRole::Assistant:
|
||||
role = "assistant";
|
||||
break;
|
||||
case ChatRole::Tool:
|
||||
case ChatRole::FileEdit:
|
||||
continue;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
QString content
|
||||
= message.attachments.isEmpty()
|
||||
? message.content
|
||||
: message.content + "\n\nAttached files list:"
|
||||
+ std::accumulate(
|
||||
message.attachments.begin(),
|
||||
message.attachments.end(),
|
||||
QString(),
|
||||
[](QString acc, const Context::ContentFile &attachment) {
|
||||
return acc
|
||||
+ QString("\nname: %1\nfile content:\n%2")
|
||||
.arg(attachment.filename, attachment.content);
|
||||
});
|
||||
|
||||
messages.append(QJsonObject{{"role", role}, {"content", content}});
|
||||
}
|
||||
|
||||
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 : "";
|
||||
}
|
||||
|
||||
void ChatModel::resetModelTo(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_messages.size())
|
||||
if (!m_history || index < 0 || index >= m_rows.size())
|
||||
return;
|
||||
m_history->resetTo(m_rows[index].messageIndex);
|
||||
}
|
||||
|
||||
QVariantList ChatModel::userMessagePreviews(int maxLength) const
|
||||
{
|
||||
QVariantList result;
|
||||
const int limit = maxLength > 4 ? maxLength : 80;
|
||||
for (int i = 0; i < m_rows.size(); ++i) {
|
||||
if (m_rows[i].kind != ChatRole::User)
|
||||
continue;
|
||||
QString preview = m_rows[i].content;
|
||||
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
||||
preview.replace(QLatin1Char('\r'), QLatin1Char(' '));
|
||||
preview.replace(QLatin1Char('\t'), QLatin1Char(' '));
|
||||
preview = preview.simplified();
|
||||
if (preview.size() > limit)
|
||||
preview = preview.left(limit - 1).trimmed() + QChar(0x2026);
|
||||
QVariantMap entry;
|
||||
entry[QStringLiteral("messageIndex")] = i;
|
||||
entry[QStringLiteral("preview")] = preview;
|
||||
result.append(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void ChatModel::setMessageUsage(
|
||||
const QString &messageId,
|
||||
int promptTokens,
|
||||
int completionTokens,
|
||||
int cachedPromptTokens,
|
||||
int reasoningTokens)
|
||||
{
|
||||
if (messageId.isEmpty())
|
||||
return;
|
||||
|
||||
if (index < m_messages.size()) {
|
||||
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
|
||||
m_messages.remove(index, m_messages.size() - index);
|
||||
endRemoveRows();
|
||||
}
|
||||
}
|
||||
m_usageByMessageId[messageId]
|
||||
= Usage{promptTokens, completionTokens, cachedPromptTokens, reasoningTokens};
|
||||
|
||||
void ChatModel::addToolExecutionStatus(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName)
|
||||
{
|
||||
QString content = toolName;
|
||||
|
||||
LOG_MESSAGE(QString("Adding tool execution status: requestId=%1, toolId=%2, toolName=%3")
|
||||
.arg(requestId, toolId, toolName));
|
||||
|
||||
if (!m_messages.isEmpty() && !toolId.isEmpty() && m_messages.last().id == toolId
|
||||
&& m_messages.last().role == ChatRole::Tool) {
|
||||
Message &lastMessage = m_messages.last();
|
||||
lastMessage.content = content;
|
||||
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};
|
||||
m_messages.append(newMessage);
|
||||
endInsertRows();
|
||||
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
|
||||
.arg(m_messages.size() - 1)
|
||||
.arg(toolId));
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::updateToolResult(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
|
||||
{
|
||||
if (m_messages.isEmpty() || toolId.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Cannot update tool result: messages empty=%1, toolId empty=%2")
|
||||
.arg(m_messages.isEmpty())
|
||||
.arg(toolId.isEmpty()));
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_MESSAGE(
|
||||
QString("Updating tool result: requestId=%1, toolId=%2, toolName=%3, result length=%4")
|
||||
.arg(requestId, toolId, toolName)
|
||||
.arg(result.length()));
|
||||
|
||||
bool toolMessageFound = false;
|
||||
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;
|
||||
emit dataChanged(index(i), index(i));
|
||||
toolMessageFound = true;
|
||||
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
|
||||
break;
|
||||
for (int i = 0; i < m_rows.size(); ++i) {
|
||||
if (m_rows[i].messageId == messageId) {
|
||||
emit dataChanged(
|
||||
index(i),
|
||||
index(i),
|
||||
{Roles::PromptTokens,
|
||||
Roles::CompletionTokens,
|
||||
Roles::CachedPromptTokens,
|
||||
Roles::ReasoningTokens,
|
||||
Roles::TotalTokens});
|
||||
}
|
||||
}
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
if (!toolMessageFound) {
|
||||
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
|
||||
.arg(requestId, toolId));
|
||||
int ChatModel::sessionPromptTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
if (m_history) {
|
||||
for (const auto &m : m_history->messages())
|
||||
total += m_usageByMessageId.value(m.id()).prompt;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const QString marker = "QODEASSIST_FILE_EDIT:";
|
||||
if (result.contains(marker)) {
|
||||
LOG_MESSAGE(QString("File edit marker detected in tool result"));
|
||||
|
||||
int markerPos = result.indexOf(marker);
|
||||
int jsonStart = markerPos + marker.length();
|
||||
|
||||
if (jsonStart < result.length()) {
|
||||
QString jsonStr = result.mid(jsonStart);
|
||||
|
||||
QJsonParseError parseError;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError);
|
||||
|
||||
if (parseError.error != QJsonParseError::NoError) {
|
||||
LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2")
|
||||
.arg(parseError.offset)
|
||||
.arg(parseError.errorString()));
|
||||
} else if (!doc.isObject()) {
|
||||
LOG_MESSAGE(
|
||||
QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray()));
|
||||
} else {
|
||||
QJsonObject editData = doc.object();
|
||||
|
||||
QString editId = editData.value("edit_id").toString();
|
||||
|
||||
if (editId.isEmpty()) {
|
||||
editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch());
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId));
|
||||
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message fileEditMsg;
|
||||
fileEditMsg.role = ChatRole::FileEdit;
|
||||
fileEditMsg.content = result;
|
||||
fileEditMsg.id = editId;
|
||||
m_messages.append(fileEditMsg);
|
||||
endInsertRows();
|
||||
|
||||
LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId));
|
||||
}
|
||||
}
|
||||
int ChatModel::sessionCompletionTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
if (m_history) {
|
||||
for (const auto &m : m_history->messages())
|
||||
total += m_usageByMessageId.value(m.id()).completion;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void ChatModel::addThinkingBlock(
|
||||
const QString &requestId, const QString &thinking, const QString &signature)
|
||||
int ChatModel::sessionCachedPromptTokens() const
|
||||
{
|
||||
LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3")
|
||||
.arg(requestId)
|
||||
.arg(thinking.length())
|
||||
.arg(signature.length()));
|
||||
|
||||
QString displayContent = thinking;
|
||||
if (!signature.isEmpty()) {
|
||||
displayContent += "\n[Signature: " + signature.left(40) + "...]";
|
||||
int total = 0;
|
||||
if (m_history) {
|
||||
for (const auto &m : m_history->messages())
|
||||
total += m_usageByMessageId.value(m.id()).cached;
|
||||
}
|
||||
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message thinkingMessage;
|
||||
thinkingMessage.role = ChatRole::Thinking;
|
||||
thinkingMessage.content = displayContent;
|
||||
thinkingMessage.id = requestId;
|
||||
thinkingMessage.isRedacted = false;
|
||||
thinkingMessage.signature = signature;
|
||||
m_messages.append(thinkingMessage);
|
||||
endInsertRows();
|
||||
LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2")
|
||||
.arg(m_messages.size() - 1).arg(signature.length()));
|
||||
return total;
|
||||
}
|
||||
|
||||
void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature)
|
||||
int ChatModel::sessionTotalTokens() const
|
||||
{
|
||||
LOG_MESSAGE(
|
||||
QString("Adding redacted thinking block: requestId=%1, signature length=%2")
|
||||
.arg(requestId)
|
||||
.arg(signature.length()));
|
||||
|
||||
QString displayContent = "[Thinking content redacted by safety systems]";
|
||||
if (!signature.isEmpty()) {
|
||||
displayContent += "\n[Signature: " + signature.left(40) + "...]";
|
||||
}
|
||||
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message thinkingMessage;
|
||||
thinkingMessage.role = ChatRole::Thinking;
|
||||
thinkingMessage.content = displayContent;
|
||||
thinkingMessage.id = requestId;
|
||||
thinkingMessage.isRedacted = true;
|
||||
thinkingMessage.signature = signature;
|
||||
m_messages.append(thinkingMessage);
|
||||
endInsertRows();
|
||||
LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2")
|
||||
.arg(m_messages.size() - 1).arg(signature.length()));
|
||||
return sessionPromptTokens() + sessionCompletionTokens();
|
||||
}
|
||||
|
||||
void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent)
|
||||
void ChatModel::setChatFilePath(const QString &filePath)
|
||||
{
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages[i].id == messageId) {
|
||||
m_messages[i].content = newContent;
|
||||
emit dataChanged(index(i), index(i));
|
||||
LOG_MESSAGE(QString("Updated message content for id: %1").arg(messageId));
|
||||
break;
|
||||
}
|
||||
}
|
||||
m_chatFilePath = filePath;
|
||||
}
|
||||
|
||||
void ChatModel::setLoadingFromHistory(bool loading)
|
||||
QString ChatModel::chatFilePath() const
|
||||
{
|
||||
m_loadingFromHistory = loading;
|
||||
LOG_MESSAGE(QString("ChatModel loading from history: %1").arg(loading ? "true" : "false"));
|
||||
}
|
||||
|
||||
bool ChatModel::isLoadingFromHistory() const
|
||||
{
|
||||
return m_loadingFromHistory;
|
||||
}
|
||||
|
||||
void ChatModel::onFileEditApplied(const QString &editId)
|
||||
{
|
||||
updateFileEditStatus(editId, "applied", "Successfully applied");
|
||||
}
|
||||
|
||||
void ChatModel::onFileEditRejected(const QString &editId)
|
||||
{
|
||||
updateFileEditStatus(editId, "rejected", "Rejected by user");
|
||||
}
|
||||
|
||||
void ChatModel::onFileEditArchived(const QString &editId)
|
||||
{
|
||||
updateFileEditStatus(editId, "archived", "Archived (from previous conversation turn)");
|
||||
}
|
||||
|
||||
void ChatModel::updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage)
|
||||
{
|
||||
const QString marker = "QODEASSIST_FILE_EDIT:";
|
||||
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages[i].role == ChatRole::FileEdit && m_messages[i].id == editId) {
|
||||
const QString &content = m_messages[i].content;
|
||||
|
||||
if (content.contains(marker)) {
|
||||
int markerPos = content.indexOf(marker);
|
||||
int jsonStart = markerPos + marker.length();
|
||||
|
||||
if (jsonStart < content.length()) {
|
||||
QString jsonStr = content.mid(jsonStart);
|
||||
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
|
||||
|
||||
if (doc.isObject()) {
|
||||
QJsonObject editData = doc.object();
|
||||
|
||||
editData["status"] = status;
|
||||
editData["status_message"] = statusMessage;
|
||||
|
||||
QString updatedContent = marker
|
||||
+ QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact));
|
||||
|
||||
m_messages[i].content = updatedContent;
|
||||
|
||||
emit dataChanged(index(i), index(i));
|
||||
|
||||
LOG_MESSAGE(QString("Updated FileEdit message status: editId=%1, status=%2")
|
||||
.arg(editId, status));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -23,92 +8,133 @@
|
||||
#include "MessagePart.hpp"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QPointer>
|
||||
#include <QVector>
|
||||
#include <QtQmlIntegration>
|
||||
|
||||
#include "context/ContentFile.hpp"
|
||||
namespace QodeAssist {
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
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 };
|
||||
enum Roles {
|
||||
RoleType = Qt::UserRole,
|
||||
Content,
|
||||
Attachments,
|
||||
IsRedacted,
|
||||
Images,
|
||||
PromptTokens,
|
||||
CompletionTokens,
|
||||
CachedPromptTokens,
|
||||
ReasoningTokens,
|
||||
TotalTokens
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
struct Message
|
||||
{
|
||||
ChatRole role;
|
||||
QString content;
|
||||
QString id;
|
||||
bool isRedacted = false;
|
||||
QString signature = QString();
|
||||
|
||||
QList<Context::ContentFile> attachments;
|
||||
};
|
||||
|
||||
explicit ChatModel(QObject *parent = nullptr);
|
||||
|
||||
void setHistory(ConversationHistory *history);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
Q_INVOKABLE void addMessage(
|
||||
const QString &content,
|
||||
ChatRole role,
|
||||
const QString &id,
|
||||
const QList<Context::ContentFile> &attachments = {});
|
||||
Q_INVOKABLE void clear();
|
||||
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
|
||||
|
||||
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);
|
||||
Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const;
|
||||
|
||||
void addToolExecutionStatus(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName);
|
||||
void updateToolResult(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QString &result);
|
||||
void addThinkingBlock(
|
||||
const QString &requestId, const QString &thinking, const QString &signature);
|
||||
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
|
||||
void updateMessageContent(const QString &messageId, const QString &newContent);
|
||||
|
||||
void setLoadingFromHistory(bool loading);
|
||||
bool isLoadingFromHistory() const;
|
||||
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 setChatFilePath(const QString &filePath);
|
||||
QString chatFilePath() const;
|
||||
|
||||
signals:
|
||||
void tokensThresholdChanged();
|
||||
void modelReseted();
|
||||
void sessionUsageChanged();
|
||||
|
||||
private slots:
|
||||
void onFileEditApplied(const QString &editId);
|
||||
void onFileEditRejected(const QString &editId);
|
||||
void onFileEditArchived(const QString &editId);
|
||||
void onHistoryMessageAdded(int index);
|
||||
void onHistoryMessageUpdated(int index);
|
||||
void onHistoryCleared();
|
||||
void onHistoryReset();
|
||||
void onFileEditStatusChanged(const QString &editId);
|
||||
|
||||
private:
|
||||
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage);
|
||||
|
||||
QVector<Message> m_messages;
|
||||
bool m_loadingFromHistory = false;
|
||||
struct AttachmentRef
|
||||
{
|
||||
QString fileName;
|
||||
QString storedPath;
|
||||
};
|
||||
struct ImageRef
|
||||
{
|
||||
QString fileName;
|
||||
QString storedPath;
|
||||
QString mediaType;
|
||||
};
|
||||
struct Row
|
||||
{
|
||||
ChatRole kind = ChatRole::Assistant;
|
||||
int messageIndex = -1;
|
||||
QString messageId;
|
||||
QString content;
|
||||
bool isRedacted = false;
|
||||
QString editId;
|
||||
QVector<AttachmentRef> attachments;
|
||||
QVector<ImageRef> images;
|
||||
};
|
||||
struct Usage
|
||||
{
|
||||
int prompt = 0;
|
||||
int completion = 0;
|
||||
int cached = 0;
|
||||
int reasoning = 0;
|
||||
};
|
||||
|
||||
void rebuildAll();
|
||||
void reprojectTail(int startMessageIndex);
|
||||
int startMessageIndexFor(int messageIndex) const;
|
||||
int firstRowForMessage(int messageIndex) const;
|
||||
QHash<QString, QString> buildToolResultMap() const;
|
||||
void appendRowsForMessage(
|
||||
int messageIndex, const QHash<QString, QString> &toolResults, QVector<Row> &out) const;
|
||||
QString overlayFileEditStatus(const QString &content, const QString &editId) const;
|
||||
QVariantList buildAttachmentList(const QVector<AttachmentRef> &attachments) const;
|
||||
QVariantList buildImageList(const QVector<ImageRef> &images) const;
|
||||
|
||||
QPointer<ConversationHistory> m_history;
|
||||
QVector<Row> m_rows;
|
||||
QHash<QString, Usage> m_usageByMessageId;
|
||||
QString m_chatFilePath;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message)
|
||||
|
||||
Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart)
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QPointer>
|
||||
#include <QQuickItem>
|
||||
#include <QVariantList>
|
||||
|
||||
#include "ChatFileManager.hpp"
|
||||
#include "ChatModel.hpp"
|
||||
#include "ClientInterface.hpp"
|
||||
#include "llmcore/PromptProviderChat.hpp"
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
class SessionManager;
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatCompressor;
|
||||
class ChatAgentController;
|
||||
class FileEditController;
|
||||
class InputTokenCounter;
|
||||
class ChatHistoryStore;
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatRootView : public QQuickItem
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL)
|
||||
Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL)
|
||||
Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL)
|
||||
Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL)
|
||||
Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL)
|
||||
@@ -46,26 +49,30 @@ class ChatRootView : public QQuickItem
|
||||
Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL)
|
||||
Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL)
|
||||
Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL)
|
||||
Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
|
||||
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
|
||||
Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL)
|
||||
Q_PROPERTY(bool isThinkingMode READ isThinkingMode WRITE setIsThinkingMode NOTIFY isThinkingModeChanged FINAL)
|
||||
Q_PROPERTY(
|
||||
bool toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged FINAL)
|
||||
Q_PROPERTY(bool useTools READ useTools NOTIFY useToolsChanged FINAL)
|
||||
Q_PROPERTY(bool useThinking READ useThinking NOTIFY useThinkingChanged FINAL)
|
||||
Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL)
|
||||
|
||||
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
|
||||
Q_PROPERTY(QStringList availableChatAgents READ availableChatAgents NOTIFY availableChatAgentsChanged FINAL)
|
||||
Q_PROPERTY(QString currentChatAgent READ currentChatAgent WRITE setCurrentChatAgent NOTIFY currentChatAgentChanged FINAL)
|
||||
Q_PROPERTY(QStringList availableRoles READ availableRoles NOTIFY availableRolesChanged FINAL)
|
||||
Q_PROPERTY(QString currentRole READ currentRole WRITE setCurrentRole NOTIFY currentRoleChanged 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;
|
||||
|
||||
void saveHistory(const QString &filePath);
|
||||
void loadHistory(const QString &filePath);
|
||||
@@ -75,18 +82,33 @@ public:
|
||||
|
||||
void autosave();
|
||||
QString getAutosaveFilePath() const;
|
||||
QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const;
|
||||
|
||||
QStringList attachmentFiles() const;
|
||||
QStringList linkedFiles() const;
|
||||
|
||||
Q_INVOKABLE void showAttachFilesDialog();
|
||||
Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths);
|
||||
Q_INVOKABLE void removeFileFromAttachList(int index);
|
||||
Q_INVOKABLE void showLinkFilesDialog();
|
||||
Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths);
|
||||
Q_INVOKABLE void removeFileFromLinkList(int index);
|
||||
Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const;
|
||||
Q_INVOKABLE void showAddImageDialog();
|
||||
Q_INVOKABLE bool isImageFile(const QString &filePath) const;
|
||||
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
|
||||
Q_INVOKABLE bool isSendShortcut(int key, int modifiers) const;
|
||||
QString sendShortcutText() const;
|
||||
Q_INVOKABLE void setIsSyncOpenFiles(bool state);
|
||||
Q_INVOKABLE void openChatHistoryFolder();
|
||||
Q_INVOKABLE void openRulesFolder();
|
||||
Q_INVOKABLE void openSettings();
|
||||
|
||||
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
||||
|
||||
Q_INVOKABLE void relocateToSplit();
|
||||
Q_INVOKABLE void relocateToWindow();
|
||||
|
||||
void consumePendingChatFile();
|
||||
|
||||
Q_INVOKABLE void updateInputTokensCount();
|
||||
int inputTokensCount() const;
|
||||
@@ -98,6 +120,7 @@ public:
|
||||
void onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath);
|
||||
|
||||
QString chatFileName() const;
|
||||
Q_INVOKABLE QString chatFilePath() const;
|
||||
void setRecentFilePath(const QString &filePath);
|
||||
bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath);
|
||||
|
||||
@@ -112,17 +135,11 @@ public:
|
||||
void setRequestProgressStatus(bool state);
|
||||
|
||||
QString lastErrorMessage() const;
|
||||
|
||||
QVariantList activeRules() const;
|
||||
int activeRulesCount() const;
|
||||
Q_INVOKABLE QString getRuleContent(int index);
|
||||
Q_INVOKABLE void refreshRules();
|
||||
|
||||
bool isAgentMode() const;
|
||||
void setIsAgentMode(bool newIsAgentMode);
|
||||
bool isThinkingMode() const;
|
||||
void setIsThinkingMode(bool newIsThinkingMode);
|
||||
bool toolsSupportEnabled() const;
|
||||
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
|
||||
|
||||
bool useTools() const;
|
||||
bool useThinking() const;
|
||||
|
||||
Q_INVOKABLE void applyFileEdit(const QString &editId);
|
||||
Q_INVOKABLE void rejectFileEdit(const QString &editId);
|
||||
@@ -132,7 +149,20 @@ public:
|
||||
Q_INVOKABLE void applyAllFileEditsForCurrentMessage();
|
||||
Q_INVOKABLE void undoAllFileEditsForCurrentMessage();
|
||||
Q_INVOKABLE void updateCurrentMessageEditsStats();
|
||||
|
||||
|
||||
Q_INVOKABLE void compressCurrentChat();
|
||||
Q_INVOKABLE void cancelCompression();
|
||||
|
||||
Q_INVOKABLE void loadAvailableChatAgents();
|
||||
QStringList availableChatAgents() const;
|
||||
QString currentChatAgent() const;
|
||||
void setCurrentChatAgent(const QString &name);
|
||||
|
||||
Q_INVOKABLE void loadAvailableRoles();
|
||||
QStringList availableRoles() const;
|
||||
QString currentRole() const;
|
||||
void setCurrentRole(const QString &roleId);
|
||||
|
||||
int currentMessageTotalEdits() const;
|
||||
int currentMessageAppliedEdits() const;
|
||||
int currentMessagePendingEdits() const;
|
||||
@@ -141,6 +171,15 @@ public:
|
||||
QString lastInfoMessage() const;
|
||||
|
||||
bool isThinkingSupport() const;
|
||||
|
||||
bool isCompressing() const;
|
||||
|
||||
bool isInEditor() const;
|
||||
void setInEditor(bool value);
|
||||
|
||||
QString chatTitle() const;
|
||||
|
||||
Q_INVOKABLE void requestNewChat();
|
||||
|
||||
public slots:
|
||||
void sendMessage(const QString &message);
|
||||
@@ -148,10 +187,10 @@ public slots:
|
||||
void cancelRequest();
|
||||
void clearAttachmentFiles();
|
||||
void clearLinkedFiles();
|
||||
void clearMessages();
|
||||
|
||||
signals:
|
||||
void chatModelChanged();
|
||||
void currentTemplateChanged();
|
||||
void attachmentFilesChanged();
|
||||
void linkedFilesChanged();
|
||||
void inputTokensCountChanged();
|
||||
@@ -167,44 +206,87 @@ signals:
|
||||
|
||||
void lastErrorMessageChanged();
|
||||
void lastInfoMessageChanged();
|
||||
void activeRulesChanged();
|
||||
void activeRulesCountChanged();
|
||||
void sendShortcutTextChanged();
|
||||
|
||||
void isAgentModeChanged();
|
||||
void isThinkingModeChanged();
|
||||
void toolsSupportEnabledChanged();
|
||||
void useToolsChanged();
|
||||
void useThinkingChanged();
|
||||
void currentMessageEditsStatsChanged();
|
||||
|
||||
void isThinkingSupportChanged();
|
||||
|
||||
private:
|
||||
void updateFileEditStatus(const QString &editId, const QString &status);
|
||||
QString getChatsHistoryDir() const;
|
||||
QString getSuggestedFileName() const;
|
||||
void availableChatAgentsChanged();
|
||||
void currentChatAgentChanged();
|
||||
void availableRolesChanged();
|
||||
void currentRoleChanged();
|
||||
|
||||
void isCompressingChanged();
|
||||
void compressionCompleted(const QString &compressedChatPath);
|
||||
void compressionFailed(const QString &error);
|
||||
|
||||
void isInEditorChanged();
|
||||
void chatTitleChanged();
|
||||
|
||||
void openFilesChanged();
|
||||
|
||||
void closeHostRequested();
|
||||
|
||||
private:
|
||||
QString computeChatTitle() const;
|
||||
void triggerOpenChatCommand(Utils::Id commandId);
|
||||
void handOffSession();
|
||||
bool deferSendForAutoCompress(
|
||||
const QString &message,
|
||||
const QStringList &attachments,
|
||||
const QStringList &linkedFiles);
|
||||
void dispatchSend(
|
||||
const QString &message,
|
||||
const QStringList &attachments,
|
||||
const QStringList &linkedFiles);
|
||||
bool hasImageAttachments(const QStringList &attachments) const;
|
||||
|
||||
SessionFileRegistry *sessionFileRegistry() const;
|
||||
Skills::SkillsManager *skillsManager() const;
|
||||
AgentFactory *agentFactory() const;
|
||||
SessionManager *sessionManager() const;
|
||||
|
||||
QodeAssist::ConversationHistory *m_history;
|
||||
ChatModel *m_chatModel;
|
||||
LLMCore::PromptProviderChat m_promptProvider;
|
||||
ClientInterface *m_clientInterface;
|
||||
QString m_currentTemplate;
|
||||
ChatFileManager *m_fileManager;
|
||||
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 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;
|
||||
bool m_isAgentMode;
|
||||
bool m_isThinkingMode;
|
||||
|
||||
QString m_currentMessageRequestId;
|
||||
int m_currentMessageTotalEdits{0};
|
||||
int m_currentMessageAppliedEdits{0};
|
||||
int m_currentMessagePendingEdits{0};
|
||||
int m_currentMessageRejectedEdits{0};
|
||||
|
||||
QString m_lastInfoMessage;
|
||||
|
||||
QString m_currentRole = QStringLiteral("developer");
|
||||
QStringList m_availableRoles;
|
||||
|
||||
ChatCompressor *m_chatCompressor;
|
||||
ChatAgentController *m_agentController;
|
||||
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;
|
||||
mutable QPointer<AgentFactory> m_agentFactory;
|
||||
mutable QPointer<SessionManager> m_sessionManager;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -1,36 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "Logger.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QUuid>
|
||||
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <MessageSerializer.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
|
||||
#include "context/ChangesManager.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
const QString ChatSerializer::VERSION = "0.1";
|
||||
namespace {
|
||||
|
||||
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
|
||||
const QString kFileEditMarker = QStringLiteral("QODEASSIST_FILE_EDIT:");
|
||||
|
||||
// Legacy (<= 0.2) per-row ChatRole values, kept only for importing old chat files.
|
||||
enum class LegacyRole { System = 0, User = 1, Assistant = 2, Tool = 3, FileEdit = 4, Thinking = 5 };
|
||||
|
||||
void registerEditFromResult(const QString &result)
|
||||
{
|
||||
const int pos = result.indexOf(kFileEditMarker);
|
||||
if (pos < 0)
|
||||
return;
|
||||
const QString jsonStr = result.mid(pos + kFileEditMarker.length());
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
|
||||
if (!doc.isObject())
|
||||
return;
|
||||
const QJsonObject obj = doc.object();
|
||||
const QString editId = obj.value("edit_id").toString();
|
||||
const QString filePath = obj.value("file").toString();
|
||||
if (editId.isEmpty() || filePath.isEmpty())
|
||||
return;
|
||||
Context::ChangesManager::instance().addFileEdit(
|
||||
editId,
|
||||
filePath,
|
||||
obj.value("old_content").toString(),
|
||||
obj.value("new_content").toString(),
|
||||
/*autoApply=*/false,
|
||||
/*isFromHistory=*/true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const QString ChatSerializer::VERSION = "0.3";
|
||||
|
||||
SerializationResult ChatSerializer::saveToFile(
|
||||
const ConversationHistory *history, const QString &filePath)
|
||||
{
|
||||
if (!history)
|
||||
return {false, "No conversation history"};
|
||||
|
||||
if (!ensureDirectoryExists(filePath)) {
|
||||
return {false, "Failed to create directory structure"};
|
||||
}
|
||||
@@ -40,9 +74,7 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
|
||||
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
QJsonObject root = serializeChat(model);
|
||||
QJsonDocument doc(root);
|
||||
|
||||
QJsonDocument doc(serializeChat(history));
|
||||
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
|
||||
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
|
||||
}
|
||||
@@ -50,8 +82,12 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath)
|
||||
SerializationResult ChatSerializer::loadFromFile(
|
||||
ConversationHistory *history, const QString &filePath)
|
||||
{
|
||||
if (!history)
|
||||
return {false, "No conversation history"};
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return {false, QString("Failed to open file for reading: %1").arg(filePath)};
|
||||
@@ -63,79 +99,140 @@ SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString
|
||||
return {false, QString("JSON parse error: %1").arg(error.errorString())};
|
||||
}
|
||||
|
||||
QJsonObject root = doc.object();
|
||||
QString version = root["version"].toString();
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
const QString version = root["version"].toString();
|
||||
if (!validateVersion(version)) {
|
||||
return {false, QString("Unsupported version: %1").arg(version)};
|
||||
}
|
||||
|
||||
if (!deserializeChat(model, root)) {
|
||||
return {false, "Failed to deserialize chat data"};
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
if (version == VERSION)
|
||||
return loadCurrent(history, root);
|
||||
return loadLegacy(history, root);
|
||||
}
|
||||
|
||||
QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message)
|
||||
{
|
||||
QJsonObject messageObj;
|
||||
messageObj["role"] = static_cast<int>(message.role);
|
||||
messageObj["content"] = message.content;
|
||||
messageObj["id"] = message.id;
|
||||
messageObj["isRedacted"] = message.isRedacted;
|
||||
if (!message.signature.isEmpty()) {
|
||||
messageObj["signature"] = message.signature;
|
||||
}
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json)
|
||||
{
|
||||
ChatModel::Message message;
|
||||
message.role = static_cast<ChatModel::ChatRole>(json["role"].toInt());
|
||||
message.content = json["content"].toString();
|
||||
message.id = json["id"].toString();
|
||||
message.isRedacted = json["isRedacted"].toBool(false);
|
||||
message.signature = json["signature"].toString();
|
||||
return message;
|
||||
}
|
||||
|
||||
QJsonObject ChatSerializer::serializeChat(const ChatModel *model)
|
||||
QJsonObject ChatSerializer::serializeChat(const ConversationHistory *history)
|
||||
{
|
||||
QJsonArray messagesArray;
|
||||
for (const auto &message : model->getChatHistory()) {
|
||||
messagesArray.append(serializeMessage(message));
|
||||
}
|
||||
for (const auto &message : history->messages())
|
||||
messagesArray.append(MessageSerializer::toJson(message));
|
||||
|
||||
QJsonObject root;
|
||||
root["version"] = VERSION;
|
||||
root["messages"] = messagesArray;
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json)
|
||||
SerializationResult ChatSerializer::loadCurrent(ConversationHistory *history, const QJsonObject &root)
|
||||
{
|
||||
QJsonArray messagesArray = json["messages"].toArray();
|
||||
QVector<ChatModel::Message> messages;
|
||||
messages.reserve(messagesArray.size());
|
||||
history->clear();
|
||||
|
||||
for (const auto &messageValue : messagesArray) {
|
||||
messages.append(deserializeMessage(messageValue.toObject()));
|
||||
const QJsonArray messagesArray = root["messages"].toArray();
|
||||
for (const auto &value : messagesArray) {
|
||||
bool ok = false;
|
||||
Message message = MessageSerializer::fromJson(value.toObject(), &ok);
|
||||
if (ok)
|
||||
history->append(std::move(message));
|
||||
}
|
||||
|
||||
model->clear();
|
||||
|
||||
model->setLoadingFromHistory(true);
|
||||
|
||||
for (const auto &message : messages) {
|
||||
model->addMessage(message.content, message.role, message.id);
|
||||
}
|
||||
|
||||
model->setLoadingFromHistory(false);
|
||||
registerHistoricalFileEdits(history);
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
return true;
|
||||
SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, const QJsonObject &root)
|
||||
{
|
||||
history->clear();
|
||||
|
||||
const QJsonArray arr = root["messages"].toArray();
|
||||
int i = 0;
|
||||
while (i < arr.size()) {
|
||||
const QJsonObject mj = arr[i].toObject();
|
||||
const auto role = static_cast<LegacyRole>(mj["role"].toInt());
|
||||
|
||||
if (role == LegacyRole::Tool) {
|
||||
Message assistant(Message::Role::Assistant);
|
||||
Message toolResults(Message::Role::User);
|
||||
while (i < arr.size()
|
||||
&& static_cast<LegacyRole>(arr[i].toObject()["role"].toInt()) == LegacyRole::Tool) {
|
||||
const QJsonObject tj = arr[i].toObject();
|
||||
const QString toolName = tj["toolName"].toString();
|
||||
const QString id = tj["id"].toString();
|
||||
if (!toolName.isEmpty()) {
|
||||
assistant.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
|
||||
id, toolName, tj["toolArguments"].toObject()));
|
||||
toolResults.appendBlock(std::make_unique<LLMQore::ToolResultContent>(
|
||||
id, tj["toolResult"].toString()));
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (!assistant.blocks().empty()) {
|
||||
history->append(std::move(assistant));
|
||||
history->append(std::move(toolResults));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
++i;
|
||||
|
||||
if (role == LegacyRole::FileEdit)
|
||||
continue; // derived from the tool result in the new model
|
||||
|
||||
if (role == LegacyRole::Thinking) {
|
||||
const QString content = mj["content"].toString();
|
||||
const QString signature = mj["signature"].toString();
|
||||
Message assistant(Message::Role::Assistant);
|
||||
if (mj["isRedacted"].toBool(false)) {
|
||||
assistant.appendBlock(
|
||||
std::make_unique<LLMQore::RedactedThinkingContent>(signature));
|
||||
} else {
|
||||
const int sigPos = content.indexOf(QStringLiteral("\n[Signature:"));
|
||||
const QString thinking = sigPos >= 0 ? content.left(sigPos) : content;
|
||||
assistant.appendBlock(
|
||||
std::make_unique<LLMQore::ThinkingContent>(thinking, signature));
|
||||
}
|
||||
history->append(std::move(assistant));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role == LegacyRole::User) {
|
||||
Message user(Message::Role::User, mj["id"].toString());
|
||||
user.appendBlock(std::make_unique<LLMQore::TextContent>(mj["content"].toString()));
|
||||
for (const auto &a : mj["attachments"].toArray()) {
|
||||
const QJsonObject ao = a.toObject();
|
||||
user.appendBlock(std::make_unique<StoredAttachmentContent>(
|
||||
ao["fileName"].toString(), ao["storedPath"].toString()));
|
||||
}
|
||||
for (const auto &im : mj["images"].toArray()) {
|
||||
const QJsonObject io = im.toObject();
|
||||
user.appendBlock(std::make_unique<StoredImageContent>(
|
||||
io["fileName"].toString(),
|
||||
io["storedPath"].toString(),
|
||||
io["mediaType"].toString()));
|
||||
}
|
||||
history->append(std::move(user));
|
||||
} else {
|
||||
const QString content = mj["content"].toString();
|
||||
if (content.trimmed().isEmpty())
|
||||
continue;
|
||||
const Message::Role mapped
|
||||
= role == LegacyRole::System ? Message::Role::System : Message::Role::Assistant;
|
||||
Message message(mapped, mj["id"].toString());
|
||||
message.appendBlock(std::make_unique<LLMQore::TextContent>(content));
|
||||
history->append(std::move(message));
|
||||
}
|
||||
}
|
||||
|
||||
registerHistoricalFileEdits(history);
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
void ChatSerializer::registerHistoricalFileEdits(const ConversationHistory *history)
|
||||
{
|
||||
for (const auto &message : history->messages()) {
|
||||
for (const auto &block : message.blocks()) {
|
||||
if (auto *tr = dynamic_cast<LLMQore::ToolResultContent *>(block.get()))
|
||||
registerEditFromResult(tr->result());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
|
||||
@@ -147,7 +244,77 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
|
||||
|
||||
bool ChatSerializer::validateVersion(const QString &version)
|
||||
{
|
||||
return version == VERSION;
|
||||
return version == VERSION || version == "0.2" || version == "0.1";
|
||||
}
|
||||
|
||||
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
|
||||
{
|
||||
QFileInfo fileInfo(chatFilePath);
|
||||
QString baseName = fileInfo.completeBaseName();
|
||||
QString dirPath = fileInfo.absolutePath();
|
||||
return QDir(dirPath).filePath(baseName + "_content");
|
||||
}
|
||||
|
||||
bool ChatSerializer::saveContentToStorage(
|
||||
const QString &chatFilePath,
|
||||
const QString &fileName,
|
||||
const QString &base64Data,
|
||||
QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QDir dir;
|
||||
if (!dir.exists(contentFolder)) {
|
||||
if (!dir.mkpath(contentFolder)) {
|
||||
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QFileInfo originalFileInfo(fileName);
|
||||
QString extension = originalFileInfo.suffix();
|
||||
QString baseName = originalFileInfo.completeBaseName();
|
||||
QString uniqueName = QString("%1_%2.%3")
|
||||
.arg(baseName)
|
||||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
|
||||
.arg(extension);
|
||||
|
||||
QString fullPath = QDir(contentFolder).filePath(uniqueName);
|
||||
|
||||
QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
|
||||
QFile file(fullPath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.write(contentData) == -1) {
|
||||
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
storedPath = uniqueName;
|
||||
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QString fullPath = QDir(contentFolder).filePath(storedPath);
|
||||
|
||||
QFile file(fullPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QByteArray contentData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return contentData.toBase64();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
namespace QodeAssist {
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
@@ -36,18 +22,26 @@ struct SerializationResult
|
||||
class ChatSerializer
|
||||
{
|
||||
public:
|
||||
static SerializationResult saveToFile(const ChatModel *model, const QString &filePath);
|
||||
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath);
|
||||
static SerializationResult saveToFile(
|
||||
const ConversationHistory *history, const QString &filePath);
|
||||
static SerializationResult loadFromFile(ConversationHistory *history, const QString &filePath);
|
||||
|
||||
// Public for testing purposes
|
||||
static QJsonObject serializeMessage(const ChatModel::Message &message);
|
||||
static ChatModel::Message deserializeMessage(const QJsonObject &json);
|
||||
static QJsonObject serializeChat(const ChatModel *model);
|
||||
static bool deserializeChat(ChatModel *model, const QJsonObject &json);
|
||||
// Content management (images and text files)
|
||||
static QString getChatContentFolder(const QString &chatFilePath);
|
||||
static bool saveContentToStorage(
|
||||
const QString &chatFilePath,
|
||||
const QString &fileName,
|
||||
const QString &base64Data,
|
||||
QString &storedPath);
|
||||
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
|
||||
|
||||
private:
|
||||
static const QString VERSION;
|
||||
static constexpr int CURRENT_VERSION = 1;
|
||||
|
||||
static QJsonObject serializeChat(const ConversationHistory *history);
|
||||
static SerializationResult loadCurrent(ConversationHistory *history, const QJsonObject &root);
|
||||
static SerializationResult loadLegacy(ConversationHistory *history, const QJsonObject &root);
|
||||
static void registerHistoricalFileEdits(const ConversationHistory *history);
|
||||
|
||||
static bool ensureDirectoryExists(const QString &filePath);
|
||||
static bool validateVersion(const QString &version);
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatUtils.h"
|
||||
|
||||
@@ -35,22 +20,34 @@ QString ChatUtils::getSafeMarkdownText(const QString &text) const
|
||||
return text;
|
||||
}
|
||||
|
||||
bool needsSanitization = false;
|
||||
for (const QChar &ch : text) {
|
||||
if (ch.isNull() || (!ch.isPrint() && ch != '\n' && ch != '\t' && ch != '\r' && ch != ' ')) {
|
||||
needsSanitization = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsSanitization) {
|
||||
return text;
|
||||
}
|
||||
|
||||
QString safeText;
|
||||
safeText.reserve(text.size());
|
||||
safeText.reserve(text.size() + 16);
|
||||
|
||||
bool inFenced = false;
|
||||
bool inInline = false;
|
||||
|
||||
for (int i = 0; i < text.size(); ++i) {
|
||||
const QChar ch = text[i];
|
||||
|
||||
if (!inInline && i + 2 < text.size()
|
||||
&& text[i] == '`' && text[i + 1] == '`' && text[i + 2] == '`') {
|
||||
safeText.append(QStringLiteral("```"));
|
||||
inFenced = !inFenced;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inFenced && ch == '`') {
|
||||
safeText.append(ch);
|
||||
inInline = !inInline;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inFenced && !inInline && ch == '<') {
|
||||
safeText.append(QStringLiteral("<"));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (QChar ch : text) {
|
||||
if (ch.isNull()) {
|
||||
safeText.append(' ');
|
||||
} else if (ch == '\n' || ch == '\t' || ch == '\r' || ch == ' ') {
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "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
|
||||
@@ -35,30 +28,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();
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <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);
|
||||
@@ -43,9 +41,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
|
||||
|
||||
@@ -1,34 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatWidget.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QMouseEvent>
|
||||
#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);
|
||||
|
||||
setAttribute(Qt::WA_NoMousePropagation, true);
|
||||
|
||||
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::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (!hasFocus())
|
||||
setFocus(Qt::MouseFocusReason);
|
||||
|
||||
QQuickWidget::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void ChatWidget::clear()
|
||||
@@ -40,4 +74,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
|
||||
|
||||
@@ -1,41 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <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;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -1,56 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ClientInterface.hpp"
|
||||
|
||||
#include <texteditor/textdocument.h>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QUuid>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <coreplugin/editormanager/ieditor.h>
|
||||
#include <coreplugin/idocument.h>
|
||||
#include <projectexplorer/buildconfiguration.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectexplorer.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
#include <projectexplorer/target.h>
|
||||
#include <texteditor/textdocument.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QMimeDatabase>
|
||||
#include <QRegularExpression>
|
||||
#include <QUuid>
|
||||
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <ContextRenderer.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
#include <Session.hpp>
|
||||
#include <SessionManager.hpp>
|
||||
#include <SystemPromptBuilder.hpp>
|
||||
|
||||
#include "tools/ReadOriginalHistoryTool.hpp"
|
||||
#include "tools/TodoTool.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "RequestConfig.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "SkillsSettings.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include <context/ChangesManager.h>
|
||||
#include <RulesLoader.hpp>
|
||||
#include <sources/skills/SkillsManager.hpp>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ClientInterface::ClientInterface(
|
||||
ChatModel *chatModel, LLMCore::IPromptProvider *promptProvider, QObject *parent)
|
||||
namespace {
|
||||
struct StoredImage
|
||||
{
|
||||
QString fileName;
|
||||
QString storedPath;
|
||||
QString mediaType;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
ClientInterface::ClientInterface(ChatModel *chatModel, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_chatModel(chatModel)
|
||||
, m_promptProvider(promptProvider)
|
||||
, m_contextManager(new Context::ContextManager(this))
|
||||
{}
|
||||
|
||||
@@ -59,211 +73,375 @@ ClientInterface::~ClientInterface()
|
||||
cancelRequest();
|
||||
}
|
||||
|
||||
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
m_skillsManager = skillsManager;
|
||||
}
|
||||
|
||||
void ClientInterface::setSessionManager(SessionManager *sessionManager)
|
||||
{
|
||||
m_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
void ClientInterface::setHistory(ConversationHistory *history)
|
||||
{
|
||||
m_history = history;
|
||||
}
|
||||
|
||||
void ClientInterface::setActiveAgent(const QString &agentName)
|
||||
{
|
||||
m_activeAgent = agentName;
|
||||
}
|
||||
|
||||
void ClientInterface::setActiveRole(const QString &roleId)
|
||||
{
|
||||
m_activeRoleId = roleId;
|
||||
}
|
||||
|
||||
void ClientInterface::sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments,
|
||||
const QList<QString> &linkedFiles,
|
||||
bool useAgentMode)
|
||||
const QList<QString> &linkedFiles)
|
||||
{
|
||||
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
|
||||
LOG_MESSAGE("Ignoring empty chat message");
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRequest();
|
||||
m_accumulatedResponses.clear();
|
||||
|
||||
|
||||
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
|
||||
|
||||
auto attachFiles = m_contextManager->getContentFiles(attachments);
|
||||
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles);
|
||||
|
||||
auto &chatAssistantSettings = Settings::chatAssistantSettings();
|
||||
|
||||
auto providerName = Settings::generalSettings().caProvider();
|
||||
auto provider = LLMCore::ProvidersManager::instance().getProviderByName(providerName);
|
||||
|
||||
if (!provider) {
|
||||
LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName));
|
||||
return;
|
||||
QList<QString> imageFiles;
|
||||
QList<QString> textFiles;
|
||||
for (const QString &filePath : attachments) {
|
||||
if (isImageFile(filePath))
|
||||
imageFiles.append(filePath);
|
||||
else
|
||||
textFiles.append(filePath);
|
||||
}
|
||||
|
||||
auto templateName = Settings::generalSettings().caTemplate();
|
||||
auto promptTemplate = m_promptProvider->getTemplateByName(templateName);
|
||||
|
||||
if (!promptTemplate) {
|
||||
LOG_MESSAGE(QString("No template found with name: %1").arg(templateName));
|
||||
return;
|
||||
}
|
||||
|
||||
LLMCore::ContextData context;
|
||||
|
||||
const bool isToolsEnabled = Settings::toolsSettings().useTools() && useAgentMode;
|
||||
|
||||
if (chatAssistantSettings.useSystemPrompt()) {
|
||||
QString systemPrompt = chatAssistantSettings.systemPrompt();
|
||||
|
||||
auto project = LLMCore::RulesLoader::getActiveProject();
|
||||
|
||||
if (project) {
|
||||
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName());
|
||||
systemPrompt += QString("\n# Active Project path: %1").arg(project->projectDirectory().toUrlishString());
|
||||
|
||||
QString projectRules
|
||||
= LLMCore::RulesLoader::loadRulesForProject(project, LLMCore::RulesContext::Chat);
|
||||
|
||||
if (!projectRules.isEmpty()) {
|
||||
systemPrompt += QString("\n# Project Rules\n\n") + projectRules;
|
||||
QList<Context::ContentFile> storedAttachments;
|
||||
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
|
||||
auto attachFiles = m_contextManager->getContentFiles(textFiles);
|
||||
for (const auto &file : attachFiles) {
|
||||
QString storedPath;
|
||||
if (ChatSerializer::saveContentToStorage(
|
||||
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
|
||||
Context::ContentFile storedFile;
|
||||
storedFile.filename = file.filename;
|
||||
storedFile.content = storedPath;
|
||||
storedAttachments.append(storedFile);
|
||||
LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath));
|
||||
}
|
||||
} else {
|
||||
systemPrompt += QString("\n# No active project in IDE");
|
||||
}
|
||||
|
||||
if (!linkedFiles.isEmpty()) {
|
||||
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
|
||||
}
|
||||
context.systemPrompt = systemPrompt;
|
||||
} else if (!textFiles.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)")
|
||||
.arg(textFiles.size()));
|
||||
}
|
||||
|
||||
QVector<LLMCore::Message> messages;
|
||||
for (const auto &msg : m_chatModel->getChatHistory()) {
|
||||
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) {
|
||||
continue;
|
||||
QList<StoredImage> storedImages;
|
||||
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
|
||||
for (const QString &imagePath : imageFiles) {
|
||||
QString base64Data = encodeImageToBase64(imagePath);
|
||||
if (base64Data.isEmpty())
|
||||
continue;
|
||||
|
||||
QString storedPath;
|
||||
QFileInfo fileInfo(imagePath);
|
||||
if (ChatSerializer::saveContentToStorage(
|
||||
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
|
||||
storedImages.append(
|
||||
{fileInfo.fileName(), storedPath, getMediaTypeForImage(imagePath)});
|
||||
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
|
||||
}
|
||||
}
|
||||
|
||||
LLMCore::Message apiMessage;
|
||||
apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant";
|
||||
apiMessage.content = msg.content;
|
||||
apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking);
|
||||
apiMessage.isRedacted = msg.isRedacted;
|
||||
apiMessage.signature = msg.signature;
|
||||
|
||||
messages.append(apiMessage);
|
||||
}
|
||||
context.history = messages;
|
||||
|
||||
LLMCore::LLMConfig config;
|
||||
config.requestType = LLMCore::RequestType::Chat;
|
||||
config.provider = provider;
|
||||
config.promptTemplate = promptTemplate;
|
||||
if (provider->providerID() == LLMCore::ProviderID::GoogleAI) {
|
||||
QString stream = QString{"streamGenerateContent?alt=sse"};
|
||||
config.url = QUrl(QString("%1/models/%2:%3")
|
||||
.arg(
|
||||
Settings::generalSettings().caUrl(),
|
||||
Settings::generalSettings().caModel(),
|
||||
stream));
|
||||
} else {
|
||||
config.url
|
||||
= QString("%1%2").arg(Settings::generalSettings().caUrl(), provider->chatEndpoint());
|
||||
config.providerRequest
|
||||
= {{"model", Settings::generalSettings().caModel()}, {"stream", true}};
|
||||
} else if (!imageFiles.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)")
|
||||
.arg(imageFiles.size()));
|
||||
}
|
||||
|
||||
config.apiKey = provider->apiKey();
|
||||
if (!m_sessionManager) {
|
||||
const QString error = QStringLiteral("Chat session manager is not available");
|
||||
LOG_MESSAGE(error);
|
||||
emit errorOccurred(error);
|
||||
return;
|
||||
}
|
||||
if (!m_history) {
|
||||
const QString error = QStringLiteral("Chat history is not available");
|
||||
LOG_MESSAGE(error);
|
||||
emit errorOccurred(error);
|
||||
return;
|
||||
}
|
||||
|
||||
config.provider->prepareRequest(
|
||||
config.providerRequest,
|
||||
promptTemplate,
|
||||
context,
|
||||
LLMCore::RequestType::Chat,
|
||||
isToolsEnabled,
|
||||
Settings::chatAssistantSettings().enableThinkingMode());
|
||||
QString sessionError;
|
||||
Session *session = m_sessionManager->createSession(m_activeAgent, m_history, &sessionError);
|
||||
if (!session) {
|
||||
const QString error = sessionError.isEmpty()
|
||||
? QStringLiteral("No chat agent selected")
|
||||
: sessionError;
|
||||
LOG_MESSAGE(error);
|
||||
emit errorOccurred(error);
|
||||
return;
|
||||
}
|
||||
|
||||
QString requestId = QUuid::createUuid().toString();
|
||||
QJsonObject request{{"id", requestId}};
|
||||
auto *client = session->client();
|
||||
if (!client) {
|
||||
const QString error = QStringLiteral("Chat agent has no live client");
|
||||
LOG_MESSAGE(error);
|
||||
m_sessionManager->removeSession(session);
|
||||
emit errorOccurred(error);
|
||||
return;
|
||||
}
|
||||
|
||||
auto *project = ProjectExplorer::ProjectManager::startupProject();
|
||||
Templates::ContextRenderer::Bindings bindings;
|
||||
bindings.projectDir = project ? project->projectDirectory().toFSPathString() : QString();
|
||||
bindings.homeDir = QDir::homePath();
|
||||
bindings.roleId = m_activeRoleId;
|
||||
session->setContextBindings(bindings);
|
||||
|
||||
const QString chatFilePath = m_chatFilePath;
|
||||
session->setContentLoader([chatFilePath](const QString &storedPath) {
|
||||
return ChatSerializer::loadContentFromStorage(chatFilePath, storedPath);
|
||||
});
|
||||
|
||||
m_sessionManager->toolContributors().contribute(client->tools());
|
||||
client->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
|
||||
client->setTransferTimeout(
|
||||
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
|
||||
|
||||
const QString chatContext = buildChatContextLayer(message, linkedFiles);
|
||||
if (!chatContext.isEmpty())
|
||||
session->systemPrompt()->setLayer(QStringLiteral("chat.context"), chatContext);
|
||||
|
||||
std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
|
||||
blocks.push_back(std::make_unique<LLMQore::TextContent>(message));
|
||||
|
||||
for (const auto &attachment : storedAttachments) {
|
||||
blocks.push_back(
|
||||
std::make_unique<StoredAttachmentContent>(attachment.filename, attachment.content));
|
||||
}
|
||||
|
||||
if (!storedImages.isEmpty() && session->supportsImages()) {
|
||||
for (const auto &image : storedImages) {
|
||||
blocks.push_back(std::make_unique<StoredImageContent>(
|
||||
image.fileName, image.storedPath, image.mediaType));
|
||||
}
|
||||
} else if (!storedImages.isEmpty() && !session->supportsImages()) {
|
||||
LOG_MESSAGE(QString("Agent '%1' doesn't support images, %2 ignored")
|
||||
.arg(m_activeAgent)
|
||||
.arg(storedImages.size()));
|
||||
}
|
||||
|
||||
if (!m_chatFilePath.isEmpty()) {
|
||||
if (auto *todoTool
|
||||
= qobject_cast<QodeAssist::Tools::TodoTool *>(client->tools()->tool("todo_tool"))) {
|
||||
todoTool->setCurrentSessionId(m_chatFilePath);
|
||||
}
|
||||
if (auto *historyTool = qobject_cast<QodeAssist::Tools::ReadOriginalHistoryTool *>(
|
||||
client->tools()->tool("read_original_history"))) {
|
||||
historyTool->setCurrentSessionId(m_chatFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
connect(session, &Session::event, this, [this, session](const QodeAssist::ResponseEvent &ev) {
|
||||
onSessionEvent(session, ev);
|
||||
});
|
||||
connect(
|
||||
session, &Session::finished, this,
|
||||
[this](const LLMQore::RequestID &id, const QString &) { onSessionFinished(id); });
|
||||
connect(
|
||||
session, &Session::failed, this,
|
||||
[this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) {
|
||||
onSessionFailed(id, error);
|
||||
});
|
||||
|
||||
const LLMQore::RequestID requestId = session->send(std::move(blocks));
|
||||
if (requestId.isEmpty()) {
|
||||
const QString error = QStringLiteral("Failed to start chat request for agent '%1': %2")
|
||||
.arg(m_activeAgent, session->lastError().message);
|
||||
LOG_MESSAGE(error);
|
||||
m_sessionManager->removeSession(session);
|
||||
emit errorOccurred(error);
|
||||
return;
|
||||
}
|
||||
|
||||
m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session};
|
||||
|
||||
m_activeRequests[requestId] = {request, provider};
|
||||
|
||||
emit requestStarted(requestId);
|
||||
}
|
||||
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::partialResponseReceived,
|
||||
this,
|
||||
&ClientInterface::handlePartialResponse,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::fullResponseReceived,
|
||||
this,
|
||||
&ClientInterface::handleFullResponse,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::requestFailed,
|
||||
this,
|
||||
&ClientInterface::handleRequestFailed,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::toolExecutionStarted,
|
||||
m_chatModel,
|
||||
&ChatModel::addToolExecutionStatus,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::toolExecutionCompleted,
|
||||
m_chatModel,
|
||||
&ChatModel::updateToolResult,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::continuationStarted,
|
||||
this,
|
||||
&ClientInterface::handleCleanAccumulatedData,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::thinkingBlockReceived,
|
||||
m_chatModel,
|
||||
&ChatModel::addThinkingBlock,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::redactedThinkingBlockReceived,
|
||||
m_chatModel,
|
||||
&ChatModel::addRedactedThinkingBlock,
|
||||
Qt::UniqueConnection);
|
||||
QString ClientInterface::requestIdForSession(Session *session) const
|
||||
{
|
||||
for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) {
|
||||
if (it.value().session == session)
|
||||
return it.key();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
provider->sendRequest(requestId, config.url, config.providerRequest);
|
||||
void ClientInterface::onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev)
|
||||
{
|
||||
if (ev.kind() != ResponseEvent::Kind::Usage)
|
||||
return;
|
||||
|
||||
const auto *usage = ev.as<ResponseEvents::Usage>();
|
||||
if (!usage)
|
||||
return;
|
||||
|
||||
const QString requestId = requestIdForSession(session);
|
||||
if (!requestId.isEmpty()) {
|
||||
m_chatModel->setMessageUsage(
|
||||
requestId,
|
||||
usage->inputTokens,
|
||||
usage->outputTokens,
|
||||
usage->cachedTokens,
|
||||
usage->reasoningTokens);
|
||||
}
|
||||
|
||||
emit messageUsageReceived(
|
||||
usage->inputTokens, usage->outputTokens, usage->cachedTokens, usage->reasoningTokens);
|
||||
|
||||
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
|
||||
.arg(requestId)
|
||||
.arg(usage->inputTokens)
|
||||
.arg(usage->outputTokens)
|
||||
.arg(usage->cachedTokens)
|
||||
.arg(usage->reasoningTokens));
|
||||
}
|
||||
|
||||
void ClientInterface::onSessionFinished(const QString &requestId)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
|
||||
Session *session = it.value().session;
|
||||
|
||||
QString applyError;
|
||||
if (!Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError)) {
|
||||
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
|
||||
.arg(requestId, applyError));
|
||||
}
|
||||
|
||||
emit messageReceivedCompletely();
|
||||
|
||||
m_activeRequests.erase(it);
|
||||
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->removeSession(session);
|
||||
}
|
||||
|
||||
void ClientInterface::onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
|
||||
Session *session = it.value().session;
|
||||
|
||||
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error.message));
|
||||
emit errorOccurred(error.message);
|
||||
|
||||
m_activeRequests.erase(it);
|
||||
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->removeSession(session);
|
||||
}
|
||||
|
||||
QString ClientInterface::buildChatContextLayer(
|
||||
const QString &message, const QList<QString> &linkedFiles) const
|
||||
{
|
||||
QString context;
|
||||
|
||||
auto *project = ProjectExplorer::ProjectManager::startupProject();
|
||||
if (project) {
|
||||
context += QString("# Active project: %1").arg(project->displayName());
|
||||
context += QString(
|
||||
"\n# Project source root: %1"
|
||||
"\n# All new source files, headers, QML and CMake edits MUST be "
|
||||
"created or modified under this directory. Use absolute paths "
|
||||
"rooted here, or project-relative paths.")
|
||||
.arg(project->projectDirectory().toUrlishString());
|
||||
|
||||
if (auto target = project->activeTarget()) {
|
||||
if (auto buildConfig = target->activeBuildConfiguration()) {
|
||||
context += QString(
|
||||
"\n# Build output directory (compiler artifacts only — do NOT "
|
||||
"create or edit source files here): %1")
|
||||
.arg(buildConfig->buildDirectory().toUrlishString());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
context += QString("# 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())
|
||||
context += QString("\n\n") + alwaysOnSkills;
|
||||
|
||||
const QString skillsCatalog = m_skillsManager->catalogText();
|
||||
if (!skillsCatalog.isEmpty())
|
||||
context += 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;
|
||||
context += QString("\n\n# Invoked Skill: %1\n\n%2")
|
||||
.arg(invokedSkill->name, invokedSkill->body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkedFiles.isEmpty()) {
|
||||
context += "\n\nLinked files for reference:\n";
|
||||
auto contentFiles = m_contextManager->getContentFiles(linkedFiles);
|
||||
for (const auto &file : contentFiles)
|
||||
context += QString("\nFile: %1\nContent:\n%2\n").arg(file.filename, file.content);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
void ClientInterface::clearMessages()
|
||||
{
|
||||
m_chatModel->clear();
|
||||
LOG_MESSAGE("Chat history cleared");
|
||||
if (m_history)
|
||||
m_history->clear();
|
||||
}
|
||||
|
||||
void ClientInterface::cancelRequest()
|
||||
{
|
||||
QSet<LLMCore::Provider *> providers;
|
||||
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
|
||||
if (it.value().provider) {
|
||||
providers.insert(it.value().provider);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto *provider : providers) {
|
||||
disconnect(provider, nullptr, this, nullptr);
|
||||
}
|
||||
|
||||
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
|
||||
const RequestContext &ctx = it.value();
|
||||
if (ctx.provider) {
|
||||
ctx.provider->cancelRequest(it.key());
|
||||
}
|
||||
}
|
||||
|
||||
const auto requests = m_activeRequests;
|
||||
m_activeRequests.clear();
|
||||
m_accumulatedResponses.clear();
|
||||
|
||||
LOG_MESSAGE("All requests cancelled and state cleared");
|
||||
}
|
||||
|
||||
void ClientInterface::handleLLMResponse(const QString &response, const QJsonObject &request)
|
||||
{
|
||||
const auto message = response.trimmed();
|
||||
|
||||
if (!message.isEmpty()) {
|
||||
QString messageId = request["id"].toString();
|
||||
m_chatModel->addMessage(message, ChatModel::ChatRole::Assistant, messageId);
|
||||
for (auto it = requests.begin(); it != requests.end(); ++it) {
|
||||
Session *session = it.value().session;
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->removeSession(session);
|
||||
}
|
||||
|
||||
LOG_MESSAGE("All chat requests cancelled and state cleared");
|
||||
}
|
||||
|
||||
QString ClientInterface::getCurrentFileContext() const
|
||||
@@ -290,93 +468,67 @@ QString ClientInterface::getCurrentFileContext() const
|
||||
return QString("Current file context:\n%1\nFile content:\n%2").arg(fileInfo, content);
|
||||
}
|
||||
|
||||
QString ClientInterface::getSystemPromptWithLinkedFiles(
|
||||
const QString &basePrompt, const QList<QString> &linkedFiles) const
|
||||
{
|
||||
QString updatedPrompt = basePrompt;
|
||||
|
||||
if (!linkedFiles.isEmpty()) {
|
||||
updatedPrompt += "\n\nLinked files for reference:\n";
|
||||
|
||||
auto contentFiles = m_contextManager->getContentFiles(linkedFiles);
|
||||
for (const auto &file : contentFiles) {
|
||||
updatedPrompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.filename, file.content);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedPrompt;
|
||||
}
|
||||
|
||||
Context::ContextManager *ClientInterface::contextManager() const
|
||||
{
|
||||
return m_contextManager;
|
||||
}
|
||||
|
||||
void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText)
|
||||
bool ClientInterface::isImageFile(const QString &filePath) const
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
|
||||
|
||||
m_accumulatedResponses[requestId] += partialText;
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString extension = fileInfo.suffix().toLower();
|
||||
|
||||
const RequestContext &ctx = it.value();
|
||||
handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest);
|
||||
return imageExtensions.contains(extension);
|
||||
}
|
||||
|
||||
void ClientInterface::handleFullResponse(const QString &requestId, const QString &fullText)
|
||||
QString ClientInterface::getMediaTypeForImage(const QString &filePath) const
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
static const QHash<QString, QString> mediaTypes
|
||||
= {{"png", "image/png"},
|
||||
{"jpg", "image/jpeg"},
|
||||
{"jpeg", "image/jpeg"},
|
||||
{"gif", "image/gif"},
|
||||
{"webp", "image/webp"},
|
||||
{"bmp", "image/bmp"},
|
||||
{"svg", "image/svg+xml"}};
|
||||
|
||||
const RequestContext &ctx = it.value();
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString extension = fileInfo.suffix().toLower();
|
||||
|
||||
QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId];
|
||||
|
||||
QString applyError;
|
||||
bool applySuccess = Context::ChangesManager::instance()
|
||||
.applyPendingEditsForRequest(requestId, &applyError);
|
||||
|
||||
if (!applySuccess) {
|
||||
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
|
||||
.arg(requestId, applyError));
|
||||
if (mediaTypes.contains(extension)) {
|
||||
return mediaTypes[extension];
|
||||
}
|
||||
|
||||
LOG_MESSAGE(
|
||||
"Message completed. Final response for message " + ctx.originalRequest["id"].toString()
|
||||
+ ": " + finalText);
|
||||
emit messageReceivedCompletely();
|
||||
|
||||
if (it != m_activeRequests.end()) {
|
||||
m_activeRequests.erase(it);
|
||||
}
|
||||
if (m_accumulatedResponses.contains(requestId)) {
|
||||
m_accumulatedResponses.remove(requestId);
|
||||
}
|
||||
QMimeDatabase mimeDb;
|
||||
QMimeType mimeType = mimeDb.mimeTypeForFile(filePath);
|
||||
return mimeType.name();
|
||||
}
|
||||
|
||||
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
QString ClientInterface::encodeImageToBase64(const QString &filePath) const
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
|
||||
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
|
||||
emit errorOccurred(error);
|
||||
|
||||
if (it != m_activeRequests.end()) {
|
||||
m_activeRequests.erase(it);
|
||||
}
|
||||
if (m_accumulatedResponses.contains(requestId)) {
|
||||
m_accumulatedResponses.remove(requestId);
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QByteArray imageData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return imageData.toBase64();
|
||||
}
|
||||
|
||||
void ClientInterface::handleCleanAccumulatedData(const QString &requestId)
|
||||
void ClientInterface::setChatFilePath(const QString &filePath)
|
||||
{
|
||||
m_accumulatedResponses[requestId].clear();
|
||||
LOG_MESSAGE(QString("Cleared accumulated responses for continuation request %1").arg(requestId));
|
||||
m_chatFilePath = filePath;
|
||||
m_chatModel->setChatFilePath(filePath);
|
||||
}
|
||||
|
||||
QString ClientInterface::chatFilePath() const
|
||||
{
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include "Provider.hpp"
|
||||
#include "llmcore/IPromptProvider.hpp"
|
||||
#include <ErrorInfo.hpp>
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <ResponseEvent.hpp>
|
||||
#include <context/ContextManager.hpp>
|
||||
|
||||
namespace QodeAssist {
|
||||
class SessionManager;
|
||||
class Session;
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ClientInterface : public QObject
|
||||
@@ -35,49 +31,63 @@ class ClientInterface : public QObject
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ClientInterface(
|
||||
ChatModel *chatModel, LLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
|
||||
explicit ClientInterface(ChatModel *chatModel, QObject *parent = nullptr);
|
||||
~ClientInterface();
|
||||
|
||||
void setSkillsManager(Skills::SkillsManager *skillsManager);
|
||||
void setSessionManager(SessionManager *sessionManager);
|
||||
void setHistory(ConversationHistory *history);
|
||||
void setActiveAgent(const QString &agentName);
|
||||
void setActiveRole(const QString &roleId);
|
||||
|
||||
void sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments = {},
|
||||
const QList<QString> &linkedFiles = {},
|
||||
bool useAgentMode = false);
|
||||
const QList<QString> &linkedFiles = {});
|
||||
void clearMessages();
|
||||
void cancelRequest();
|
||||
|
||||
Context::ContextManager *contextManager() const;
|
||||
|
||||
void setChatFilePath(const QString &filePath);
|
||||
QString chatFilePath() const;
|
||||
|
||||
signals:
|
||||
void errorOccurred(const QString &error);
|
||||
void messageReceivedCompletely();
|
||||
void requestStarted(const QString &requestId);
|
||||
|
||||
private slots:
|
||||
void handlePartialResponse(const QString &requestId, const QString &partialText);
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
void handleCleanAccumulatedData(const QString &requestId);
|
||||
void messageUsageReceived(
|
||||
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
|
||||
|
||||
private:
|
||||
void handleLLMResponse(const QString &response, const QJsonObject &request);
|
||||
void onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev);
|
||||
void onSessionFinished(const QString &requestId);
|
||||
void onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error);
|
||||
|
||||
QString getCurrentFileContext() const;
|
||||
QString getSystemPromptWithLinkedFiles(
|
||||
const QString &basePrompt, const QList<QString> &linkedFiles) const;
|
||||
QString buildChatContextLayer(
|
||||
const QString &message, const QList<QString> &linkedFiles) const;
|
||||
QString requestIdForSession(Session *session) const;
|
||||
bool isImageFile(const QString &filePath) const;
|
||||
QString getMediaTypeForImage(const QString &filePath) const;
|
||||
QString encodeImageToBase64(const QString &filePath) const;
|
||||
|
||||
struct RequestContext
|
||||
{
|
||||
QJsonObject originalRequest;
|
||||
LLMCore::Provider *provider;
|
||||
QPointer<Session> session;
|
||||
};
|
||||
|
||||
LLMCore::IPromptProvider *m_promptProvider = nullptr;
|
||||
ChatModel *m_chatModel;
|
||||
Context::ContextManager *m_contextManager;
|
||||
QPointer<ConversationHistory> m_history;
|
||||
Skills::SkillsManager *m_skillsManager = nullptr;
|
||||
QPointer<SessionManager> m_sessionManager;
|
||||
QString m_activeAgent;
|
||||
QString m_activeRoleId;
|
||||
QString m_chatFilePath;
|
||||
|
||||
QHash<QString, RequestContext> m_activeRequests;
|
||||
QHash<QString, QString> m_accumulatedResponses;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
278
ChatView/FileEditController.cpp
Normal file
@@ -0,0 +1,278 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "FileEditController.hpp"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
FileEditController::FileEditController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
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"));
|
||||
} 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"));
|
||||
} 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"));
|
||||
} 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::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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
49
ChatView/FileEditController.hpp
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class FileEditController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit FileEditController(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:
|
||||
QString m_currentRequestId;
|
||||
int m_totalEdits{0};
|
||||
int m_appliedEdits{0};
|
||||
int m_pendingEdits{0};
|
||||
int m_rejectedEdits{0};
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
61
ChatView/FileItem.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "FileItem.hpp"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QUrl>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <logger/Logger.hpp>
|
||||
#include <utils/filepath.h>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
FileItem::FileItem(QQuickItem *parent)
|
||||
: QQuickItem(parent)
|
||||
{}
|
||||
|
||||
void FileItem::openFileInEditor()
|
||||
{
|
||||
if (m_filePath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Utils::FilePath filePathObj = Utils::FilePath::fromString(m_filePath);
|
||||
Core::IEditor *editor = Core::EditorManager::openEditor(filePathObj);
|
||||
|
||||
if (!editor) {
|
||||
LOG_MESSAGE(QString("Failed to open file in editor: %1").arg(m_filePath));
|
||||
}
|
||||
}
|
||||
|
||||
void FileItem::openFileInExternalEditor()
|
||||
{
|
||||
if (m_filePath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool success = QDesktopServices::openUrl(QUrl::fromLocalFile(m_filePath));
|
||||
if (success) {
|
||||
LOG_MESSAGE(QString("Opened file in external application: %1").arg(m_filePath));
|
||||
} else {
|
||||
LOG_MESSAGE(QString("Failed to open file externally: %1").arg(m_filePath));
|
||||
}
|
||||
}
|
||||
|
||||
QString FileItem::filePath() const
|
||||
{
|
||||
return m_filePath;
|
||||
}
|
||||
|
||||
void FileItem::setFilePath(const QString &newFilePath)
|
||||
{
|
||||
if (m_filePath == newFilePath)
|
||||
return;
|
||||
m_filePath = newFilePath;
|
||||
emit filePathChanged();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
33
ChatView/FileItem.hpp
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QQuickItem>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class FileItem: public QQuickItem
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_NAMED_ELEMENT(FileItem)
|
||||
|
||||
Q_PROPERTY(QString filePath READ filePath WRITE setFilePath NOTIFY filePathChanged)
|
||||
|
||||
public:
|
||||
FileItem(QQuickItem *parent = nullptr);
|
||||
|
||||
Q_INVOKABLE void openFileInEditor();
|
||||
Q_INVOKABLE void openFileInExternalEditor();
|
||||
|
||||
QString filePath() const;
|
||||
void setFilePath(const QString &newFilePath);
|
||||
|
||||
signals:
|
||||
void filePathChanged();
|
||||
|
||||
private:
|
||||
QString m_filePath;
|
||||
};
|
||||
}
|
||||
427
ChatView/FileMentionItem.cpp
Normal file
@@ -0,0 +1,427 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "FileMentionItem.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QTextStream>
|
||||
|
||||
#include <coreplugin/editormanager/documentmodel.h>
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
FileMentionItem::FileMentionItem(QQuickItem *parent)
|
||||
: QQuickItem(parent)
|
||||
{}
|
||||
|
||||
QVariantList FileMentionItem::searchResults() const
|
||||
{
|
||||
return m_searchResults;
|
||||
}
|
||||
|
||||
int FileMentionItem::currentIndex() const
|
||||
{
|
||||
return m_currentIndex;
|
||||
}
|
||||
|
||||
void FileMentionItem::setCurrentIndex(int index)
|
||||
{
|
||||
if (m_currentIndex == index)
|
||||
return;
|
||||
m_currentIndex = index;
|
||||
emit currentIndexChanged();
|
||||
}
|
||||
|
||||
void FileMentionItem::updateSearch(const QString &query)
|
||||
{
|
||||
m_lastQuery = query;
|
||||
|
||||
QVariantList openFiles = getOpenFiles(query);
|
||||
QVariantList projectResults = searchProjectFiles(query);
|
||||
|
||||
QSet<QString> openPaths;
|
||||
for (const QVariant &item : std::as_const(openFiles)) {
|
||||
const QVariantMap map = item.toMap();
|
||||
openPaths.insert(map.value("absolutePath").toString());
|
||||
}
|
||||
|
||||
QVariantList combined = openFiles;
|
||||
for (const QVariant &item : std::as_const(projectResults)) {
|
||||
const QVariantMap map = item.toMap();
|
||||
if (!map.value("isProject").toBool()
|
||||
&& openPaths.contains(map.value("absolutePath").toString()))
|
||||
continue;
|
||||
combined.append(item);
|
||||
}
|
||||
|
||||
m_searchResults = combined;
|
||||
m_currentIndex = 0;
|
||||
emit searchResultsChanged();
|
||||
emit currentIndexChanged();
|
||||
}
|
||||
|
||||
void FileMentionItem::refreshSearch()
|
||||
{
|
||||
if (!m_lastQuery.isNull())
|
||||
updateSearch(m_lastQuery);
|
||||
}
|
||||
|
||||
void FileMentionItem::moveUp()
|
||||
{
|
||||
if (m_currentIndex > 0) {
|
||||
m_currentIndex--;
|
||||
emit currentIndexChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void FileMentionItem::moveDown()
|
||||
{
|
||||
if (m_currentIndex < m_searchResults.size() - 1) {
|
||||
m_currentIndex++;
|
||||
emit currentIndexChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void FileMentionItem::selectCurrent()
|
||||
{
|
||||
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size())
|
||||
return;
|
||||
|
||||
const QVariantMap item = m_searchResults[m_currentIndex].toMap();
|
||||
if (item.value("isProject").toBool()) {
|
||||
emit projectSelected(item.value("projectName").toString());
|
||||
} else {
|
||||
emit fileSelected(
|
||||
item.value("absolutePath").toString(),
|
||||
item.value("relativePath").toString(),
|
||||
item.value("projectName").toString());
|
||||
}
|
||||
}
|
||||
|
||||
void FileMentionItem::dismiss()
|
||||
{
|
||||
m_searchResults.clear();
|
||||
m_currentIndex = 0;
|
||||
emit searchResultsChanged();
|
||||
emit currentIndexChanged();
|
||||
emit dismissed();
|
||||
}
|
||||
|
||||
QVariantMap FileMentionItem::applyCurrentSelection(
|
||||
const QString &text, int cursorPosition, bool useTools)
|
||||
{
|
||||
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) {
|
||||
dismiss();
|
||||
return {};
|
||||
}
|
||||
|
||||
const QString textBefore = text.left(cursorPosition);
|
||||
const int atIndex = textBefore.lastIndexOf('@');
|
||||
if (atIndex < 0) {
|
||||
dismiss();
|
||||
return {};
|
||||
}
|
||||
|
||||
const QVariantMap item = m_searchResults[m_currentIndex].toMap();
|
||||
QString replacement;
|
||||
|
||||
if (item.value("isProject").toBool()) {
|
||||
replacement = QStringLiteral("@") + item.value("projectName").toString() + ":";
|
||||
} else {
|
||||
const QString currentQuery = textBefore.mid(atIndex + 1);
|
||||
const QVariantMap result = handleFileSelection(
|
||||
item.value("absolutePath").toString(),
|
||||
item.value("relativePath").toString(),
|
||||
item.value("projectName").toString(),
|
||||
currentQuery,
|
||||
useTools);
|
||||
|
||||
if (result.value("mode").toString() == "mention")
|
||||
replacement = result.value("mentionText").toString();
|
||||
}
|
||||
|
||||
const QString newText = text.left(atIndex) + replacement + text.mid(cursorPosition);
|
||||
const int newCursorPosition = atIndex + replacement.length();
|
||||
|
||||
dismiss();
|
||||
|
||||
return {{"text", newText}, {"cursorPosition", newCursorPosition}};
|
||||
}
|
||||
|
||||
QVariantMap FileMentionItem::handleFileSelection(
|
||||
const QString &absolutePath,
|
||||
const QString &relativePath,
|
||||
const QString &projectName,
|
||||
const QString ¤tQuery,
|
||||
bool useTools)
|
||||
{
|
||||
QVariantMap result;
|
||||
const QString fileName = relativePath.section('/', -1);
|
||||
|
||||
QString mentionKey = fileName;
|
||||
const int colonIdx = currentQuery.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const QString projPrefix = currentQuery.left(colonIdx);
|
||||
if (projPrefix.compare(projectName, Qt::CaseInsensitive) == 0)
|
||||
mentionKey = projPrefix + ":" + fileName;
|
||||
}
|
||||
|
||||
if (useTools) {
|
||||
registerMention(mentionKey, absolutePath);
|
||||
result["mode"] = QStringLiteral("mention");
|
||||
result["mentionText"] = "@" + mentionKey + " ";
|
||||
} else {
|
||||
emit fileAttachRequested({absolutePath});
|
||||
result["mode"] = QStringLiteral("attach");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void FileMentionItem::registerMention(const QString &mentionKey, const QString &absolutePath)
|
||||
{
|
||||
m_atMentionMap[mentionKey] = absolutePath;
|
||||
}
|
||||
|
||||
void FileMentionItem::clearMentions()
|
||||
{
|
||||
m_atMentionMap.clear();
|
||||
}
|
||||
|
||||
QString FileMentionItem::expandMentions(const QString &text)
|
||||
{
|
||||
QString result = text;
|
||||
|
||||
for (auto it = m_atMentionMap.constBegin(); it != m_atMentionMap.constEnd(); ++it) {
|
||||
const QString &mentionKey = it.key();
|
||||
const QString &absPath = it.value();
|
||||
const QString displayName = mentionKey.section(':', -1);
|
||||
const QString escaped = QRegularExpression::escape(mentionKey);
|
||||
|
||||
// @key:N-M -> hyperlink + inline code block
|
||||
const QRegularExpression rangeRe("@" + escaped + ":(\\d+)-(\\d+)(?=\\s|$)");
|
||||
QRegularExpressionMatchIterator matchIt = rangeRe.globalMatch(result);
|
||||
QList<QRegularExpressionMatch> matches;
|
||||
while (matchIt.hasNext())
|
||||
matches.append(matchIt.next());
|
||||
|
||||
for (int i = matches.size() - 1; i >= 0; --i) {
|
||||
const auto &m = matches[i];
|
||||
const int startLine = m.captured(1).toInt();
|
||||
const int endLine = m.captured(2).toInt();
|
||||
const QString ext = fileExtension(absPath);
|
||||
const QString snippet = readFileLines(absPath, startLine, endLine);
|
||||
const QString replacement
|
||||
= QString("[@%1:%2-%3](file://%4)\n```%5\n%6```")
|
||||
.arg(displayName)
|
||||
.arg(startLine)
|
||||
.arg(endLine)
|
||||
.arg(absPath, ext, snippet);
|
||||
result.replace(m.capturedStart(), m.capturedLength(), replacement);
|
||||
}
|
||||
|
||||
// @key -> hyperlink only
|
||||
const QRegularExpression simpleRe("@" + escaped + "(?=\\s|$)");
|
||||
result.replace(simpleRe, QString("[@%1](file://%2)").arg(displayName, absPath));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QVariantList FileMentionItem::searchProjectFiles(const QString &query)
|
||||
{
|
||||
QVariantList results;
|
||||
|
||||
struct FileResult
|
||||
{
|
||||
QString absolutePath;
|
||||
QString relativePath;
|
||||
QString projectName;
|
||||
int priority;
|
||||
};
|
||||
|
||||
const auto allProjects = ProjectExplorer::ProjectManager::projects();
|
||||
|
||||
QString projectFilter;
|
||||
QString fileQuery = query;
|
||||
const int colonIdx = query.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const QString prefix = query.left(colonIdx);
|
||||
for (auto project : allProjects) {
|
||||
if (project && project->displayName().compare(prefix, Qt::CaseInsensitive) == 0) {
|
||||
projectFilter = project->displayName();
|
||||
fileQuery = query.mid(colonIdx + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (projectFilter.isEmpty() && colonIdx < 0) {
|
||||
const QString lowerQ = query.toLower();
|
||||
for (auto project : allProjects) {
|
||||
if (!project)
|
||||
continue;
|
||||
const QString name = project->displayName();
|
||||
if (query.isEmpty() || name.toLower().startsWith(lowerQ)) {
|
||||
QVariantMap item;
|
||||
item["absolutePath"] = QString();
|
||||
item["relativePath"] = name;
|
||||
item["projectName"] = name;
|
||||
item["isProject"] = true;
|
||||
results.append(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QList<FileResult> candidates;
|
||||
const QString lowerFileQuery = fileQuery.toLower();
|
||||
const bool emptyFileQuery = fileQuery.isEmpty();
|
||||
|
||||
for (auto project : allProjects) {
|
||||
if (!project)
|
||||
continue;
|
||||
if (!projectFilter.isEmpty() && project->displayName() != projectFilter)
|
||||
continue;
|
||||
|
||||
const auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
|
||||
const QString projectDir = project->projectDirectory().path();
|
||||
const QString projectName = project->displayName();
|
||||
|
||||
for (const auto &filePath : projectFiles) {
|
||||
const QString absolutePath = filePath.path();
|
||||
const QFileInfo fileInfo(absolutePath);
|
||||
const QString fileName = fileInfo.fileName();
|
||||
const QString relativePath = QDir(projectDir).relativeFilePath(absolutePath);
|
||||
const QString lowerFileName = fileName.toLower();
|
||||
const QString lowerRelativePath = relativePath.toLower();
|
||||
|
||||
int priority = -1;
|
||||
if (emptyFileQuery) {
|
||||
priority = 3;
|
||||
} else if (lowerFileName == lowerFileQuery) {
|
||||
priority = 0;
|
||||
} else if (lowerFileName.startsWith(lowerFileQuery)) {
|
||||
priority = 1;
|
||||
} else if (lowerFileName.contains(lowerFileQuery)) {
|
||||
priority = 2;
|
||||
} else if (lowerRelativePath.contains(lowerFileQuery)) {
|
||||
priority = 3;
|
||||
}
|
||||
|
||||
if (priority >= 0)
|
||||
candidates.append({absolutePath, relativePath, projectName, priority});
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(candidates.begin(), candidates.end(), [](const FileResult &a, const FileResult &b) {
|
||||
if (a.priority != b.priority)
|
||||
return a.priority < b.priority;
|
||||
return a.relativePath < b.relativePath;
|
||||
});
|
||||
|
||||
const int maxFiles = qMax(0, 10 - results.size());
|
||||
const int count = qMin(candidates.size(), maxFiles);
|
||||
for (int i = 0; i < count; i++) {
|
||||
QVariantMap item;
|
||||
item["absolutePath"] = candidates[i].absolutePath;
|
||||
item["relativePath"] = candidates[i].relativePath;
|
||||
item["projectName"] = candidates[i].projectName;
|
||||
item["isProject"] = false;
|
||||
results.append(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
QVariantList FileMentionItem::getOpenFiles(const QString &query)
|
||||
{
|
||||
QVariantList results;
|
||||
const QString lowerQuery = query.toLower();
|
||||
const bool emptyQuery = query.isEmpty();
|
||||
QSet<QString> addedPaths;
|
||||
|
||||
auto tryAddDocument = [&](Core::IDocument *document) {
|
||||
if (!document)
|
||||
return;
|
||||
|
||||
const QString absolutePath = document->filePath().toFSPathString();
|
||||
if (absolutePath.isEmpty() || addedPaths.contains(absolutePath))
|
||||
return;
|
||||
|
||||
const QFileInfo fileInfo(absolutePath);
|
||||
const QString fileName = fileInfo.fileName();
|
||||
if (fileName.isEmpty())
|
||||
return;
|
||||
|
||||
QString relativePath = absolutePath;
|
||||
QString projectName;
|
||||
|
||||
auto project = ProjectExplorer::ProjectManager::projectForFile(document->filePath());
|
||||
if (project) {
|
||||
projectName = project->displayName();
|
||||
relativePath = QDir(project->projectDirectory().path()).relativeFilePath(absolutePath);
|
||||
}
|
||||
|
||||
if (!emptyQuery) {
|
||||
const QString lowerFileName = fileName.toLower();
|
||||
const QString lowerRelativePath = relativePath.toLower();
|
||||
if (!lowerFileName.contains(lowerQuery) && !lowerRelativePath.contains(lowerQuery))
|
||||
return;
|
||||
}
|
||||
|
||||
addedPaths.insert(absolutePath);
|
||||
|
||||
QVariantMap item;
|
||||
item["absolutePath"] = absolutePath;
|
||||
item["relativePath"] = relativePath;
|
||||
item["projectName"] = projectName;
|
||||
item["isProject"] = false;
|
||||
item["isOpen"] = true;
|
||||
results.append(item);
|
||||
};
|
||||
|
||||
if (auto current = Core::EditorManager::currentEditor())
|
||||
tryAddDocument(current->document());
|
||||
|
||||
for (auto editor : Core::EditorManager::visibleEditors())
|
||||
if (editor)
|
||||
tryAddDocument(editor->document());
|
||||
|
||||
for (auto document : Core::DocumentModel::openedDocuments())
|
||||
tryAddDocument(document);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
QString FileMentionItem::readFileLines(const QString &filePath, int startLine, int endLine)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return {};
|
||||
|
||||
QTextStream stream(&file);
|
||||
QString result;
|
||||
int lineNum = 1;
|
||||
while (!stream.atEnd()) {
|
||||
const QString line = stream.readLine();
|
||||
if (lineNum >= startLine)
|
||||
result += line + '\n';
|
||||
if (lineNum >= endLine)
|
||||
break;
|
||||
++lineNum;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString FileMentionItem::fileExtension(const QString &filePath)
|
||||
{
|
||||
const int dot = filePath.lastIndexOf('.');
|
||||
return dot >= 0 ? filePath.mid(dot + 1) : QString();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
71
ChatView/FileMentionItem.hpp
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QQuickItem>
|
||||
#include <QRegularExpression>
|
||||
#include <QVariantList>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class FileMentionItem : public QQuickItem
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QVariantList searchResults READ searchResults NOTIFY searchResultsChanged FINAL)
|
||||
Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged FINAL)
|
||||
|
||||
QML_ELEMENT
|
||||
public:
|
||||
explicit FileMentionItem(QQuickItem *parent = nullptr);
|
||||
|
||||
QVariantList searchResults() const;
|
||||
int currentIndex() const;
|
||||
void setCurrentIndex(int index);
|
||||
|
||||
Q_INVOKABLE void updateSearch(const QString &query);
|
||||
Q_INVOKABLE void refreshSearch();
|
||||
Q_INVOKABLE void moveUp();
|
||||
Q_INVOKABLE void moveDown();
|
||||
Q_INVOKABLE void selectCurrent();
|
||||
Q_INVOKABLE void dismiss();
|
||||
|
||||
Q_INVOKABLE QVariantMap handleFileSelection(
|
||||
const QString &absolutePath,
|
||||
const QString &relativePath,
|
||||
const QString &projectName,
|
||||
const QString ¤tQuery,
|
||||
bool useTools);
|
||||
|
||||
Q_INVOKABLE QVariantMap applyCurrentSelection(
|
||||
const QString &text, int cursorPosition, bool useTools);
|
||||
|
||||
Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath);
|
||||
Q_INVOKABLE void clearMentions();
|
||||
Q_INVOKABLE QString expandMentions(const QString &text);
|
||||
|
||||
signals:
|
||||
void searchResultsChanged();
|
||||
void currentIndexChanged();
|
||||
void fileSelected(const QString &absolutePath,
|
||||
const QString &relativePath,
|
||||
const QString &projectName);
|
||||
void projectSelected(const QString &projectName);
|
||||
void dismissed();
|
||||
void fileAttachRequested(const QStringList &filePaths);
|
||||
|
||||
private:
|
||||
QVariantList searchProjectFiles(const QString &query);
|
||||
QVariantList getOpenFiles(const QString &query);
|
||||
QString readFileLines(const QString &filePath, int startLine, int endLine);
|
||||
static QString fileExtension(const QString &filePath);
|
||||
|
||||
QVariantList m_searchResults;
|
||||
int m_currentIndex = 0;
|
||||
QString m_lastQuery;
|
||||
QHash<QString, QString> m_atMentionMap;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
142
ChatView/InputTokenCounter.cpp
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "InputTokenCounter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <utils/aspects.h>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "context/ContextManager.hpp"
|
||||
#include "context/TokenUtils.hpp"
|
||||
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <Message.hpp>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
InputTokenCounter::InputTokenCounter(
|
||||
ConversationHistory *history, Context::ContextManager *contextManager, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_history(history)
|
||||
, 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);
|
||||
|
||||
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::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);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_history) {
|
||||
for (const auto &message : m_history->messages()) {
|
||||
inputTokens += Context::TokenUtils::estimateTokens(message.text());
|
||||
inputTokens += 4; // + role
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
55
ChatView/InputTokenCounter.hpp
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist {
|
||||
class ConversationHistory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
class ContextManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class InputTokenCounter : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
InputTokenCounter(
|
||||
ConversationHistory *history,
|
||||
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:
|
||||
ConversationHistory *m_history;
|
||||
Context::ContextManager *m_contextManager;
|
||||
|
||||
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
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -32,11 +17,15 @@ class MessagePart
|
||||
Q_PROPERTY(MessagePartType type MEMBER type CONSTANT FINAL)
|
||||
Q_PROPERTY(QString text MEMBER text CONSTANT FINAL)
|
||||
Q_PROPERTY(QString language MEMBER language CONSTANT FINAL)
|
||||
Q_PROPERTY(QString imageData MEMBER imageData CONSTANT FINAL)
|
||||
Q_PROPERTY(QString mediaType MEMBER mediaType CONSTANT FINAL)
|
||||
QML_VALUE_TYPE(messagePart)
|
||||
public:
|
||||
MessagePartType type;
|
||||
QString text;
|
||||
QString language;
|
||||
QString imageData; // Base64 data or URL
|
||||
QString mediaType; // e.g., "image/png", "image/jpeg"
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
68
ChatView/SessionFileRegistry.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "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
|
||||
39
ChatView/SessionFileRegistry.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <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
|
||||
10
ChatView/icons/compress-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Archive/compress icon: downward arrows pointing to center with horizontal lines -->
|
||||
<line x1="12" y1="3" x2="12" y2="10" />
|
||||
<polyline points="9 7 12 10 15 7" />
|
||||
|
||||
<line x1="12" y1="21" x2="12" y2="14" />
|
||||
<polyline points="9 17 12 14 15 17" />
|
||||
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke-width="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
5
ChatView/icons/context-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h12v2H3v-2z"/>
|
||||
<circle cx="19" cy="17" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 233 B |
6
ChatView/icons/image-dark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 304 B |
5
ChatView/icons/new-chat-icon.svg
Normal 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 |
17
ChatView/icons/open-in-code.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 943 B |
@@ -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 |
6
ChatView/icons/open-in-window.svg
Normal 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 |
4
ChatView/icons/settings-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 962 B |
@@ -1,4 +1,4 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black"/>
|
||||
<path d="M6 35L38 6" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black" fill-opacity="0.6"/>
|
||||
<path d="M6 35L38 6" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
11
ChatView/icons/tools-icon-off.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_82_71)">
|
||||
<path d="M10.7777 0.0435181C14.2316 -0.253961 17.6161 0.979215 20.0629 3.42633C23.4139 6.77767 24.3012 11.6719 22.7299 15.8433C22.9016 15.988 23.0706 16.1419 23.2377 16.3072L42.2221 34.2203C42.2288 34.2268 42.2353 34.2344 42.2426 34.2408C44.4752 36.4735 44.4752 40.1064 42.2426 42.3394C40.0096 44.5717 36.4035 44.5446 34.1713 42.3121C34.1617 42.3031 34.1528 42.2937 34.144 42.2838L16.3871 23.1519C16.2254 22.9894 16.0746 22.8196 15.933 22.6451C11.7604 24.2194 6.86327 23.3335 3.50919 19.98C1.06298 17.5327 -0.171482 14.1483 0.126373 10.6949C0.160109 10.3034 0.41818 9.96685 0.78653 9.83258C1.15602 9.69759 1.57009 9.78945 1.84805 10.067L7.53555 15.7535L13.8402 13.7574L15.8363 7.4527L10.1488 1.7652C9.87057 1.48716 9.77945 1.07345 9.91348 0.704651C10.0489 0.335072 10.3852 0.0774496 10.7777 0.0435181ZM37.3656 34.7496L37.3129 34.9302L37.1586 35.4673L36.8363 35.5679L36.6195 35.2047L36.4623 34.942L36.2357 35.148L35.725 35.6148L35.5746 35.7525L35.6791 35.9283L35.9184 36.3287L35.7104 36.6548L35.1742 36.5543L34.9408 36.5093L34.8852 36.7418L34.7572 37.275L34.7123 37.4644L34.8842 37.5543L35.3891 37.8179V38.1802L34.8842 38.4449L34.7123 38.5347L34.7572 38.7242L34.8852 39.2574L34.9408 39.4898L35.1742 39.4449L35.7104 39.3433L35.9184 39.6695L35.6791 40.0709L35.5746 40.2466L35.725 40.3843L36.2357 40.8511L36.4623 41.0572L36.6195 40.7945L36.8363 40.4302L37.1586 40.5308L37.3129 41.0689L37.3656 41.2496H38.6352L38.6879 41.0689L38.8412 40.5308L39.1635 40.4302L39.3813 40.7945L39.5385 41.0572L39.765 40.8511L40.2758 40.3843L40.4262 40.2466L40.3217 40.0709L40.0815 39.6695L40.2895 39.3433L40.8266 39.4449L41.06 39.4898L41.1156 39.2574L41.2436 38.7242L41.2885 38.5347L41.1166 38.4449L40.6117 38.1802V37.8179L41.1166 37.5543L41.2885 37.4644L41.2436 37.275L41.1156 36.7418L41.06 36.5093L40.8266 36.5543L40.2895 36.6548L40.0815 36.3287L40.3217 35.9283L40.4262 35.7525L40.2758 35.6148L39.765 35.148L39.5385 34.942L39.3813 35.2047L39.1635 35.5679L38.8412 35.4673L38.6879 34.9302L38.6352 34.7496H37.3656Z" fill="black" fill-opacity="0.6"/>
|
||||
<path d="M6 36L38 7" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_82_71">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
10
ChatView/icons/tools-icon-on.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_82_50)">
|
||||
<path d="M10.7775 0.0441895C14.2315 -0.253375 17.6159 0.979824 20.0627 3.427C23.4135 6.77842 24.3011 11.6726 22.7297 15.844C22.9013 15.9886 23.0714 16.1416 23.2385 16.3069L42.2219 34.2209C42.2285 34.2274 42.2352 34.2341 42.2424 34.2405C44.475 36.4732 44.475 40.1061 42.2424 42.3391C40.0094 44.5715 36.4033 44.5444 34.1711 42.3118C34.1615 42.3028 34.1525 42.2934 34.1437 42.2834L16.3869 23.1516C16.2251 22.9891 16.0745 22.8193 15.9328 22.6448C11.7602 24.2191 6.86304 23.3333 3.50897 19.9797C1.06276 17.5324 -0.171773 14.148 0.12616 10.6946C0.159908 10.3029 0.418723 9.96644 0.787292 9.83228C1.15667 9.69748 1.56997 9.78926 1.84784 10.0667L7.53534 15.7532L13.84 13.7571L15.8361 7.45239L10.1486 1.76489C9.87052 1.48684 9.78022 1.07306 9.91425 0.704346C10.0498 0.334991 10.3852 0.0781082 10.7775 0.0441895ZM37.3654 34.7502L37.3127 34.9309L37.1584 35.468L36.8361 35.5686L36.6193 35.2053L36.4621 34.9426L36.2355 35.1487L35.7248 35.6155L35.5744 35.7532L35.6789 35.929L35.9182 36.3293L35.7101 36.6555L35.174 36.5549L34.9406 36.51L34.8849 36.7424L34.757 37.2756L34.7121 37.4651L34.884 37.5549L35.3889 37.8186V38.1809L34.884 38.4456L34.7121 38.5354L34.757 38.7249L34.8849 39.2581L34.9406 39.4905L35.174 39.4456L35.7101 39.344L35.9182 39.6702L35.6789 40.0715L35.5744 40.2473L35.7248 40.385L36.2355 40.8518L36.4621 41.0579L36.6193 40.7952L36.8361 40.4309L37.1584 40.5315L37.3127 41.0696L37.3654 41.2502H38.6349L38.6877 41.0696L38.841 40.5315L39.1633 40.4309L39.381 40.7952L39.5383 41.0579L39.7648 40.8518L40.2756 40.385L40.426 40.2473L40.3215 40.0715L40.0812 39.6702L40.2892 39.344L40.8264 39.4456L41.0598 39.4905L41.1154 39.2581L41.2433 38.7249L41.2883 38.5354L41.1164 38.4456L40.6115 38.1809V37.8186L41.1164 37.5549L41.2883 37.4651L41.2433 37.2756L41.1154 36.7424L41.0598 36.51L40.8264 36.5549L40.2892 36.6555L40.0812 36.3293L40.3215 35.929L40.426 35.7532L40.2756 35.6155L39.7648 35.1487L39.5383 34.9426L39.381 35.2053L39.1633 35.5686L38.841 35.468L38.6877 34.9309L38.6349 34.7502H37.3654Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_82_50">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
5
ChatView/icons/warning-icon.svg
Normal 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="M12 3L22 20H2L12 3Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M12 10V14" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M12 17H12.01" stroke="black" stroke-width="2.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import ChatView
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import UIControls
|
||||
|
||||
import "./dialog"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias msgModel: msgCreator.model
|
||||
property alias messageAttachments: attachmentsModel.model
|
||||
property string textFontFamily: Qt.application.font.family
|
||||
property string codeFontFamily: {
|
||||
switch (Qt.platform.os) {
|
||||
case "windows":
|
||||
return "Consolas";
|
||||
case "osx":
|
||||
return "Menlo";
|
||||
case "linux":
|
||||
return "DejaVu Sans Mono";
|
||||
default:
|
||||
return "monospace";
|
||||
}
|
||||
}
|
||||
property int textFontSize: Qt.application.font.pointSize
|
||||
property int codeFontSize: Qt.application.font.pointSize
|
||||
property int textFormat: 0
|
||||
|
||||
property bool isUserMessage: false
|
||||
property int messageIndex: -1
|
||||
|
||||
signal resetChatToMessage(int index)
|
||||
|
||||
height: msgColumn.implicitHeight + 10
|
||||
radius: 8
|
||||
color: isUserMessage ? palette.alternateBase
|
||||
: palette.base
|
||||
|
||||
HoverHandler {
|
||||
id: mouse
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: msgColumn
|
||||
|
||||
x: 5
|
||||
width: parent.width - x
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: msgCreator
|
||||
delegate: Loader {
|
||||
id: msgCreatorDelegate
|
||||
// Fix me:
|
||||
// why does `required property MessagePart modelData` not work?
|
||||
required property var modelData
|
||||
|
||||
Layout.preferredWidth: root.width
|
||||
sourceComponent: {
|
||||
// If `required property MessagePart modelData` is used
|
||||
// and conversion to MessagePart fails, you're left
|
||||
// with a nullptr. This tests that to prevent crashing.
|
||||
if(!modelData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch(modelData.type) {
|
||||
case MessagePartType.Text: return textComponent;
|
||||
case MessagePartType.Code: return codeBlockComponent;
|
||||
default: return textComponent;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: textComponent
|
||||
TextComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: codeBlockComponent
|
||||
CodeBlockComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: attachmentsFlow
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: attachmentsModel.model && attachmentsModel.model.length > 0
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: attachmentsModel
|
||||
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
height: attachText.implicitHeight + 8
|
||||
width: attachText.implicitWidth + 16
|
||||
radius: 4
|
||||
color: palette.button
|
||||
border.width: 1
|
||||
border.color: palette.mid
|
||||
|
||||
Text {
|
||||
id: attachText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
color: palette.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: userMessageMarker
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 3
|
||||
height: root.height - root.radius
|
||||
color: "#92BD6C"
|
||||
radius: root.radius
|
||||
visible: root.isUserMessage
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: stopButtonId
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
}
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
visible: root.isUserMessage && mouse.hovered
|
||||
onClicked: function() {
|
||||
root.resetChatToMessage(root.messageIndex)
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Reset chat to this message and edit")
|
||||
ToolTip.delay: 500
|
||||
}
|
||||
|
||||
component TextComponent : TextBlock {
|
||||
required property var itemData
|
||||
height: implicitHeight + 10
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 10
|
||||
text: textFormat == Text.MarkdownText ? utils.getSafeMarkdownText(itemData.text)
|
||||
: itemData.text
|
||||
font.family: root.textFontFamily
|
||||
font.pointSize: root.textFontSize
|
||||
textFormat: {
|
||||
if (root.textFormat == 0) {
|
||||
return Text.MarkdownText
|
||||
} else if (root.textFormat == 1) {
|
||||
return Text.RichText
|
||||
} else {
|
||||
return Text.PlainText
|
||||
}
|
||||
}
|
||||
|
||||
ChatUtils {
|
||||
id: utils
|
||||
}
|
||||
}
|
||||
|
||||
component CodeBlockComponent : CodeBlock {
|
||||
id: codeblock
|
||||
|
||||
required property var itemData
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 10
|
||||
right: parent.right
|
||||
rightMargin: 10
|
||||
}
|
||||
|
||||
code: itemData.text
|
||||
language: itemData.language
|
||||
codeFontFamily: root.codeFontFamily
|
||||
codeFontSize: root.codeFontSize
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -24,7 +9,9 @@ import QtQuick.Layouts
|
||||
import ChatView
|
||||
import UIControls
|
||||
import Qt.labs.platform as Platform
|
||||
import "./parts"
|
||||
|
||||
import "./chatparts"
|
||||
import "./controls"
|
||||
|
||||
ChatRootView {
|
||||
id: root
|
||||
@@ -33,6 +20,9 @@ ChatRootView {
|
||||
colorGroup: SystemPalette.Active
|
||||
}
|
||||
|
||||
property bool hasActiveError: false
|
||||
readonly property color errorColor: "#d32f2f"
|
||||
|
||||
palette {
|
||||
window: sysPalette.window
|
||||
windowText: sysPalette.windowText
|
||||
@@ -57,7 +47,41 @@ ChatRootView {
|
||||
color: palette.window
|
||||
}
|
||||
|
||||
SplitDropZone {
|
||||
anchors.fill: parent
|
||||
z: 99
|
||||
|
||||
onFilesDroppedToAttach: (urlStrings) => {
|
||||
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
|
||||
if (localPaths.length > 0) {
|
||||
root.addFilesToAttachList(localPaths)
|
||||
}
|
||||
}
|
||||
|
||||
onFilesDroppedToLink: (urlStrings) => {
|
||||
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
|
||||
if (localPaths.length > 0) {
|
||||
root.addFilesToLinkList(localPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QoABusyOverlay {
|
||||
id: compressingOverlay
|
||||
|
||||
z: 50
|
||||
|
||||
anchors.fill: mainColumn
|
||||
anchors.topMargin: topBar.height
|
||||
anchors.bottomMargin: bottomBar.height
|
||||
|
||||
active: root.isCompressing
|
||||
text: qsTr("Compressing chat…")
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: mainColumn
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
@@ -67,53 +91,136 @@ 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")
|
||||
}
|
||||
openChatHistory.onClicked: root.openChatHistoryFolder()
|
||||
rulesButton.onClicked: rulesViewer.open()
|
||||
activeRulesCount: root.activeRulesCount
|
||||
contextButton.onClicked: contextViewer.open()
|
||||
pinButton {
|
||||
visible: typeof _chatview !== 'undefined'
|
||||
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
|
||||
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
|
||||
}
|
||||
agentModeSwitch {
|
||||
checked: root.isAgentMode
|
||||
enabled: root.toolsSupportEnabled
|
||||
onToggled: {
|
||||
root.isAgentMode = agentModeSwitch.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"
|
||||
onClicked: {
|
||||
if (typeof _chatview !== 'undefined')
|
||||
root.relocateToSplit()
|
||||
else
|
||||
root.relocateToWindow()
|
||||
}
|
||||
}
|
||||
thinkingMode {
|
||||
checked: root.isThinkingMode
|
||||
enabled: root.isThinkingSupport
|
||||
onCheckedChanged: {
|
||||
root.isThinkingMode = thinkingMode.checked
|
||||
relocateTooltip.text: (typeof _chatview !== 'undefined')
|
||||
? qsTr("Move this chat to an editor tab")
|
||||
: qsTr("Move this chat to a separate window")
|
||||
settingsButton.onClicked: root.openSettings()
|
||||
agentSelector {
|
||||
model: root.availableChatAgents
|
||||
displayText: root.currentChatAgent
|
||||
onActivated: function(index) {
|
||||
root.currentChatAgent = root.availableChatAgents[index]
|
||||
}
|
||||
|
||||
Component.onCompleted: root.loadAvailableChatAgents()
|
||||
|
||||
popup.onAboutToShow: {
|
||||
root.loadAvailableChatAgents()
|
||||
}
|
||||
}
|
||||
roleSelector {
|
||||
model: root.availableRoles
|
||||
displayText: root.currentRole
|
||||
onActivated: function(index) {
|
||||
root.currentRole = root.availableRoles[index]
|
||||
}
|
||||
|
||||
Component.onCompleted: root.loadAvailableRoles()
|
||||
|
||||
popup.onAboutToShow: {
|
||||
root.loadAvailableRoles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 2
|
||||
|
||||
MessageNavigator {
|
||||
id: messageNavigator
|
||||
|
||||
Layout.preferredWidth: 16
|
||||
Layout.fillHeight: true
|
||||
Layout.topMargin: 4
|
||||
Layout.bottomMargin: 4
|
||||
|
||||
chatModel: root.chatModel
|
||||
|
||||
onMessageClicked: function(messageIndex) {
|
||||
chatListView.userScrolledUp = true
|
||||
chatListView.positionViewAtIndex(messageIndex, ListView.Beginning)
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: chatListView
|
||||
|
||||
signal hideServiceComponents(int itemIndex)
|
||||
property bool userScrolledUp: false
|
||||
|
||||
function syncNavigatorCurrent() {
|
||||
const top = indexAt(10, contentY + 4)
|
||||
messageNavigator.updateCurrentFromModelIndex(top)
|
||||
}
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
leftMargin: 5
|
||||
leftMargin: 3
|
||||
model: root.chatModel
|
||||
clip: true
|
||||
spacing: 0
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
cacheBuffer: 2000
|
||||
|
||||
onContentYChanged: Qt.callLater(syncNavigatorCurrent)
|
||||
|
||||
onMovingChanged: {
|
||||
if (moving) {
|
||||
userScrolledUp = !atYEnd
|
||||
}
|
||||
}
|
||||
|
||||
onAtYEndChanged: {
|
||||
if (atYEnd) {
|
||||
userScrolledUp = false
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Loader {
|
||||
id: componentLoader
|
||||
|
||||
@@ -134,11 +241,6 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (componentLoader.sourceComponent == chatItemComponent) {
|
||||
chatListView.hideServiceComponents(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header: Item {
|
||||
@@ -150,12 +252,54 @@ ChatRootView {
|
||||
id: scroll
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: scrollToBottomButton
|
||||
|
||||
anchors {
|
||||
bottom: parent.bottom
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
bottomMargin: 10
|
||||
}
|
||||
width: 36
|
||||
height: 36
|
||||
radius: 18
|
||||
color: palette.button
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
visible: chatListView.userScrolledUp
|
||||
opacity: 0.9
|
||||
z: 100
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "▼"
|
||||
font.pixelSize: 14
|
||||
color: palette.buttonText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
chatListView.userScrolledUp = false
|
||||
root.scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on visible {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
onCountChanged: {
|
||||
root.scrollToBottom()
|
||||
if (!userScrolledUp) {
|
||||
root.scrollToBottom()
|
||||
}
|
||||
Qt.callLater(syncNavigatorCurrent)
|
||||
}
|
||||
|
||||
onContentHeightChanged: {
|
||||
if (atYEnd) {
|
||||
if (!userScrolledUp && atYEnd) {
|
||||
root.scrollToBottom()
|
||||
}
|
||||
}
|
||||
@@ -167,8 +311,11 @@ ChatRootView {
|
||||
id: chatItemInstance
|
||||
|
||||
width: parent.width
|
||||
chatViewport: chatListView
|
||||
msgModel: root.chatModel.processMessageContent(model.content)
|
||||
messageAttachments: model.attachments
|
||||
messageImages: model.images
|
||||
chatFilePath: root.chatFilePath()
|
||||
isUserMessage: model.roleType === ChatModel.User
|
||||
messageIndex: index
|
||||
textFontFamily: root.textFontFamily
|
||||
@@ -176,39 +323,36 @@ 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
|
||||
messageInput.cursorPosition = model.content.length
|
||||
root.chatModel.resetModelTo(idx)
|
||||
}
|
||||
|
||||
onOpenFileRequested: function(filePath) {
|
||||
root.openFileInEditor(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: toolMessageComponent
|
||||
|
||||
ToolStatusItem {
|
||||
id: toolsItem
|
||||
|
||||
ToolBlock {
|
||||
width: parent.width
|
||||
toolContent: model.content
|
||||
|
||||
Connections {
|
||||
target: chatListView
|
||||
function onHideServiceComponents(itemIndex) {
|
||||
if (index !== itemIndex) {
|
||||
toolsItem.headerOpacity = 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: fileEditMessageComponent
|
||||
|
||||
FileEditItem {
|
||||
FileEditBlock {
|
||||
width: parent.width
|
||||
editContent: model.content
|
||||
|
||||
@@ -233,9 +377,7 @@ ChatRootView {
|
||||
Component {
|
||||
id: thinkingMessageComponent
|
||||
|
||||
ThinkingStatusItem {
|
||||
id: thinking
|
||||
|
||||
ThinkingBlock {
|
||||
width: parent.width
|
||||
thinkingContent: {
|
||||
let content = model.content
|
||||
@@ -246,18 +388,10 @@ ChatRootView {
|
||||
return content
|
||||
}
|
||||
isRedacted: model.isRedacted !== undefined ? model.isRedacted : false
|
||||
|
||||
Connections {
|
||||
target: chatListView
|
||||
function onHideServiceComponents(itemIndex) {
|
||||
if (index !== itemIndex) {
|
||||
thinking.headerOpacity = 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
id: view
|
||||
@@ -269,11 +403,10 @@ ChatRootView {
|
||||
QQC.TextArea {
|
||||
id: messageInput
|
||||
|
||||
placeholderText: Qt.platform.os === "osx"
|
||||
? qsTr("Type your message here... (⌘+↩ to send)")
|
||||
: qsTr("Type your message here... (Ctrl+Enter to send)")
|
||||
placeholderText: qsTr("Type your message here... (%1 to send)").arg(root.sendShortcutText)
|
||||
placeholderTextColor: palette.mid
|
||||
color: palette.text
|
||||
wrapMode: TextArea.Wrap
|
||||
background: Rectangle {
|
||||
radius: 2
|
||||
color: palette.base
|
||||
@@ -292,7 +425,71 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
onTextChanged: root.calculateMessageTokensCount(messageInput.text)
|
||||
onTextChanged: {
|
||||
root.calculateMessageTokensCount(messageInput.text)
|
||||
var cursorPos = messageInput.cursorPosition
|
||||
var textBefore = messageInput.text.substring(0, cursorPos)
|
||||
|
||||
var atIndex = textBefore.lastIndexOf('@')
|
||||
if (atIndex >= 0) {
|
||||
var query = textBefore.substring(atIndex + 1)
|
||||
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
|
||||
fileMentionPopup.updateSearch(query)
|
||||
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) {
|
||||
if (fileMentionPopup.visible) {
|
||||
if (event.key === Qt.Key_Down) {
|
||||
fileMentionPopup.moveDown()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
fileMentionPopup.moveUp()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
root.applyMentionSelection()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
fileMentionPopup.dismiss()
|
||||
event.accepted = true
|
||||
}
|
||||
} 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
|
||||
}
|
||||
} else if (root.isSendShortcut(event.key, event.modifiers)) {
|
||||
root.sendChatMessage()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
@@ -382,35 +579,36 @@ ChatRootView {
|
||||
Layout.preferredWidth: parent.width
|
||||
Layout.preferredHeight: 40
|
||||
|
||||
isCompressing: root.isCompressing
|
||||
isProcessing: root.isRequestInProgress
|
||||
sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage()
|
||||
: root.cancelRequest()
|
||||
sendButton.icon.source: !root.isRequestInProgress ? "qrc:/qt/qml/ChatView/icons/chat-icon.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/chat-pause-icon.svg"
|
||||
sendButton.ToolTip.text: !root.isRequestInProgress ? qsTr("Send message to LLM %1").arg(Qt.platform.os === "osx" ? "Cmd+Return" : "Ctrl+Return")
|
||||
: qsTr("Stop")
|
||||
sendButton.icon.source: root.isRequestInProgress
|
||||
? ""
|
||||
: (root.hasActiveError ? "qrc:/qt/qml/ChatView/icons/warning-icon.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/chat-icon.svg")
|
||||
sendButton.text: root.isRequestInProgress ? qsTr("Stop") : qsTr("Send")
|
||||
sendButton.accentColor: (root.hasActiveError && !root.isRequestInProgress)
|
||||
? root.errorColor : "transparent"
|
||||
sendButtonTooltip.text: root.isRequestInProgress
|
||||
? qsTr("Stop")
|
||||
: (root.hasActiveError
|
||||
? root.lastErrorMessage
|
||||
: qsTr("Send message to LLM %1").arg(root.sendShortcutText))
|
||||
compressButton.onClicked: compressConfirmDialog.open()
|
||||
cancelCompressButton.onClicked: root.cancelCompression()
|
||||
syncOpenFiles {
|
||||
checked: root.isSyncOpenFiles
|
||||
onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
|
||||
}
|
||||
attachFiles.onClicked: root.showAttachFilesDialog()
|
||||
attachImages.onClicked: root.showAddImageDialog()
|
||||
linkFiles.onClicked: root.showLinkFilesDialog()
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
id: sendMessageShortcut
|
||||
|
||||
sequences: ["Ctrl+Return", "Ctrl+Enter"]
|
||||
context: Qt.WindowShortcut
|
||||
onActivated: {
|
||||
if (messageInput.activeFocus && !Qt.inputMethod.visible) {
|
||||
root.sendChatMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
root.chatModel.clear()
|
||||
root.clearMessages()
|
||||
root.clearAttachmentFiles()
|
||||
root.updateInputTokensCount()
|
||||
}
|
||||
@@ -419,19 +617,197 @@ ChatRootView {
|
||||
Qt.callLater(chatListView.positionViewAtEnd)
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
messageInput.forceActiveFocus()
|
||||
}
|
||||
|
||||
property Item focusGuard: Window.activeFocusItem
|
||||
onFocusGuardChanged: Qt.callLater(returnFocusToInputIfNeeded)
|
||||
|
||||
function returnFocusToInputIfNeeded() {
|
||||
var item = Window.activeFocusItem
|
||||
if (!item || item === messageInput)
|
||||
return
|
||||
if (item.cursorVisible !== undefined || item.selectByMouse !== undefined)
|
||||
return
|
||||
if (item.popup !== undefined)
|
||||
return
|
||||
var p = item
|
||||
while (p) {
|
||||
if (p === root) {
|
||||
messageInput.forceActiveFocus()
|
||||
return
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
}
|
||||
|
||||
function applyMentionSelection() {
|
||||
var result = fileMentionPopup.applyCurrentSelection(
|
||||
messageInput.text, messageInput.cursorPosition, root.useTools)
|
||||
if (result.text !== undefined) {
|
||||
messageInput.text = result.text
|
||||
messageInput.cursorPosition = result.cursorPosition
|
||||
}
|
||||
}
|
||||
|
||||
function 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(messageInput.text)
|
||||
root.hasActiveError = false
|
||||
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
|
||||
messageInput.text = ""
|
||||
fileMentionPopup.clearMentions()
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
Toast {
|
||||
id: errorToast
|
||||
z: 1000
|
||||
Dialog {
|
||||
id: compressConfirmDialog
|
||||
|
||||
color: Qt.rgba(0.8, 0.2, 0.2, 0.9)
|
||||
border.color: Qt.darker(infoToast.color, 1.3)
|
||||
toastTextColor: "#FFFFFF"
|
||||
anchors.centerIn: parent
|
||||
title: qsTr("Compress Chat")
|
||||
modal: true
|
||||
standardButtons: Dialog.Yes | Dialog.No
|
||||
|
||||
Label {
|
||||
text: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.")
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
onAccepted: root.compressCurrentChat()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: errorBanner
|
||||
|
||||
z: 1000
|
||||
visible: root.hasActiveError && root.lastErrorMessage.length > 0
|
||||
|
||||
width: parent.width / 2
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 10
|
||||
anchors.bottomMargin: bottomBar.height + 48
|
||||
|
||||
height: visible ? errorRow.implicitHeight + 12 : 0
|
||||
|
||||
color: Qt.rgba(0.83, 0.18, 0.18, 0.96)
|
||||
radius: 6
|
||||
border.color: Qt.darker(color, 1.3)
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 6
|
||||
spacing: 8
|
||||
|
||||
TextEdit {
|
||||
Layout.fillWidth: true
|
||||
text: root.lastErrorMessage
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: 12
|
||||
wrapMode: TextEdit.Wrap
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
selectionColor: Qt.darker(errorBanner.color, 1.3)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: copyErrorButton
|
||||
|
||||
property bool copied: false
|
||||
|
||||
Layout.alignment: Qt.AlignTop
|
||||
implicitWidth: copyErrorLabel.implicitWidth + 18
|
||||
implicitHeight: 22
|
||||
radius: 4
|
||||
color: copyErrorMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.28)
|
||||
: Qt.rgba(1, 1, 1, 0.16)
|
||||
border.color: Qt.rgba(1, 1, 1, 0.45)
|
||||
border.width: 1
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
Text {
|
||||
id: copyErrorLabel
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: copyErrorButton.copied ? qsTr("Copied") : qsTr("Copy")
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: copyErrorMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.copyToClipboard(root.lastErrorMessage)
|
||||
copyErrorButton.copied = true
|
||||
copyErrorResetTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: copyErrorResetTimer
|
||||
|
||||
interval: 1500
|
||||
onTriggered: copyErrorButton.copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeErrorButton
|
||||
|
||||
Layout.alignment: Qt.AlignTop
|
||||
implicitWidth: 22
|
||||
implicitHeight: 22
|
||||
radius: 4
|
||||
color: closeErrorMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.28) : "transparent"
|
||||
border.color: Qt.rgba(1, 1, 1, 0.45)
|
||||
border.width: closeErrorMouse.containsMouse ? 1 : 0
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "✕"
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeErrorMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.hasActiveError = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Toast {
|
||||
@@ -443,26 +819,22 @@ ChatRootView {
|
||||
toastTextColor: "#FFFFFF"
|
||||
}
|
||||
|
||||
RulesViewer {
|
||||
id: rulesViewer
|
||||
ContextViewer {
|
||||
id: contextViewer
|
||||
|
||||
width: parent.width * 0.8
|
||||
height: parent.height * 0.8
|
||||
width: Math.min(parent.width * 0.85, 800)
|
||||
height: Math.min(parent.height * 0.85, 700)
|
||||
x: (parent.width - width) / 2
|
||||
y: (parent.height - height) / 2
|
||||
|
||||
activeRules: root.activeRules
|
||||
ruleContentAreaText: root.getRuleContent(rulesViewer.rulesCurrentIndex)
|
||||
|
||||
onRefreshRules: root.refreshRules()
|
||||
onOpenRulesFolder: root.openRulesFolder()
|
||||
onOpenSettings: root.openSettings()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onLastErrorMessageChanged() {
|
||||
if (root.lastErrorMessage.length > 0) {
|
||||
errorToast.show(root.lastErrorMessage)
|
||||
root.hasActiveError = true
|
||||
}
|
||||
}
|
||||
function onLastInfoMessageChanged() {
|
||||
@@ -470,9 +842,43 @@ ChatRootView {
|
||||
infoToast.show(root.lastInfoMessage)
|
||||
}
|
||||
}
|
||||
function onOpenFilesChanged() {
|
||||
if (fileMentionPopup.visible)
|
||||
Qt.callLater(fileMentionPopup.refreshSearch)
|
||||
}
|
||||
}
|
||||
|
||||
FileMentionPopup {
|
||||
id: fileMentionPopup
|
||||
|
||||
z: 999
|
||||
width: Math.min(480, root.width - 20)
|
||||
|
||||
x: Math.max(5, Math.min(view.x + 5, root.width - width - 5))
|
||||
y: view.y - height - 4
|
||||
|
||||
onSelectionRequested: root.applyMentionSelection()
|
||||
|
||||
onFileAttachRequested: function(filePaths) {
|
||||
root.addFilesToAttachList(filePaths)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
407
ChatView/qml/chatparts/ChatItem.qml
Normal file
@@ -0,0 +1,407 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import ChatView
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import UIControls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias msgModel: msgCreator.model
|
||||
property alias messageAttachments: attachmentsModel.model
|
||||
property alias messageImages: imagesModel.model
|
||||
property string chatFilePath: ""
|
||||
property string textFontFamily: Qt.application.font.family
|
||||
property string codeFontFamily: {
|
||||
switch (Qt.platform.os) {
|
||||
case "windows":
|
||||
return "Consolas";
|
||||
case "osx":
|
||||
return "Menlo";
|
||||
case "linux":
|
||||
return "DejaVu Sans Mono";
|
||||
default:
|
||||
return "monospace";
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
height: msgColumn.implicitHeight + 10
|
||||
radius: 8
|
||||
color: isUserMessage ? palette.alternateBase
|
||||
: palette.base
|
||||
|
||||
HoverHandler {
|
||||
id: mouse
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: msgColumn
|
||||
|
||||
x: 5
|
||||
width: parent.width - x
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: msgCreator
|
||||
delegate: Loader {
|
||||
id: msgCreatorDelegate
|
||||
// Fix me:
|
||||
// why does `required property MessagePart modelData` not work?
|
||||
required property var modelData
|
||||
|
||||
Layout.preferredWidth: root.width
|
||||
sourceComponent: {
|
||||
// If `required property MessagePart modelData` is used
|
||||
// and conversion to MessagePart fails, you're left
|
||||
// with a nullptr. This tests that to prevent crashing.
|
||||
if(!modelData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch(modelData.type) {
|
||||
case MessagePartType.Text: return textComponent;
|
||||
case MessagePartType.Code: return codeBlockComponent;
|
||||
default: return textComponent;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: textComponent
|
||||
TextComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: codeBlockComponent
|
||||
CodeBlockComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: attachmentsFlow
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: attachmentsModel.model && attachmentsModel.model.length > 0
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: attachmentsModel
|
||||
|
||||
delegate: AttachmentComponent {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
itemData: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: imagesFlow
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: imagesModel.model && imagesModel.model.length > 0
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 10
|
||||
|
||||
Repeater {
|
||||
id: imagesModel
|
||||
|
||||
delegate: ImageComponent {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
itemData: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
id: userMessageMarker
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 3
|
||||
height: root.height - root.radius
|
||||
color: "#92BD6C"
|
||||
radius: root.radius
|
||||
visible: root.isUserMessage
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: stopButtonId
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
}
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
visible: root.isUserMessage && mouse.hovered
|
||||
onClicked: function() {
|
||||
root.resetChatToMessage(root.messageIndex)
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: stopButtonId.hovered
|
||||
text: qsTr("Reset chat to this message and edit")
|
||||
delay: 500
|
||||
}
|
||||
}
|
||||
|
||||
component TextComponent : TextBlock {
|
||||
required property var itemData
|
||||
height: implicitHeight + 10
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 10
|
||||
text: textFormat == Text.MarkdownText ? utils.getSafeMarkdownText(itemData.text)
|
||||
: itemData.text
|
||||
font.family: root.textFontFamily
|
||||
font.pointSize: root.textFontSize
|
||||
textFormat: {
|
||||
if (root.textFormat == 0) {
|
||||
return Text.MarkdownText
|
||||
} else if (root.textFormat == 1) {
|
||||
return Text.RichText
|
||||
} else {
|
||||
return Text.PlainText
|
||||
}
|
||||
}
|
||||
|
||||
onLinkActivated: function(link) {
|
||||
if (link.startsWith("file://")) {
|
||||
var filePath = link.replace(/^file:\/\//, "")
|
||||
root.openFileRequested(filePath)
|
||||
} else {
|
||||
Qt.openUrlExternally(link)
|
||||
}
|
||||
}
|
||||
|
||||
ChatUtils {
|
||||
id: utils
|
||||
}
|
||||
}
|
||||
|
||||
component CodeBlockComponent : CodeBlock {
|
||||
id: codeblock
|
||||
|
||||
required property var itemData
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 10
|
||||
right: parent.right
|
||||
rightMargin: 10
|
||||
}
|
||||
|
||||
code: itemData.text
|
||||
language: itemData.language
|
||||
codeFontFamily: root.codeFontFamily
|
||||
codeFontSize: root.codeFontSize
|
||||
viewport: root.chatViewport
|
||||
}
|
||||
|
||||
component AttachmentComponent : Rectangle {
|
||||
required property var itemData
|
||||
|
||||
height: attachFileText.implicitHeight + 8
|
||||
width: attachFileText.implicitWidth + 16
|
||||
radius: 4
|
||||
color: attachFileMouseArea.containsMouse ? Qt.lighter(palette.button, 1.1) : palette.button
|
||||
border.width: 1
|
||||
border.color: palette.mid
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
|
||||
FileItem {
|
||||
id: fileItem
|
||||
filePath: itemData.filePath || ""
|
||||
}
|
||||
|
||||
Text {
|
||||
id: attachFileText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: (itemData.fileName || "")
|
||||
color: palette.buttonText
|
||||
font.pointSize: root.textFontSize - 1
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: attachFileMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.modifiers & Qt.ShiftModifier) {
|
||||
fileItem.openFileInExternalEditor()
|
||||
} else {
|
||||
fileItem.openFileInEditor()
|
||||
}
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: attachFileMouseArea.containsMouse
|
||||
text: qsTr("Click: Open in Qt Creator\nShift+Click: Open in System Editor")
|
||||
delay: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component ImageComponent : Rectangle {
|
||||
required property var itemData
|
||||
|
||||
readonly property int maxImageWidth: Math.min(400, root.width - 40)
|
||||
readonly property int maxImageHeight: 300
|
||||
|
||||
width: Math.min(imageDisplay.implicitWidth, maxImageWidth) + 16
|
||||
height: imageDisplay.implicitHeight + fileNameText.implicitHeight + 16
|
||||
radius: 4
|
||||
color: imageMouseArea.containsMouse ? Qt.lighter(palette.base, 1.05) : palette.base
|
||||
border.width: 1
|
||||
border.color: palette.mid
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
|
||||
FileItem {
|
||||
id: imageFileItem
|
||||
filePath: itemData.filePath || ""
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 4
|
||||
|
||||
Image {
|
||||
id: imageDisplay
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.maximumWidth: parent.parent.maxImageWidth
|
||||
Layout.maximumHeight: parent.parent.maxImageHeight
|
||||
|
||||
source: itemData.imageUrl ? itemData.imageUrl : ""
|
||||
|
||||
sourceSize.width: parent.parent.maxImageWidth
|
||||
sourceSize.height: parent.parent.maxImageHeight
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: true
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
|
||||
QoABusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: imageDisplay.status === Image.Loading
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Failed to load image")
|
||||
visible: imageDisplay.status === Image.Error
|
||||
color: palette.placeholderText
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: fileNameText
|
||||
|
||||
Layout.fillWidth: true
|
||||
text: itemData.fileName || ""
|
||||
color: palette.text
|
||||
font.pointSize: root.textFontSize - 1
|
||||
elide: Text.ElideMiddle
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: imageMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.modifiers & Qt.ShiftModifier) {
|
||||
imageFileItem.openFileInExternalEditor()
|
||||
} else {
|
||||
imageFileItem.openFileInEditor()
|
||||
}
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: imageMouseArea.containsMouse
|
||||
text: qsTr("Click: Open in Qt Creator\nShift+Click: Open in System Editor")
|
||||
delay: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -29,6 +14,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
|
||||
@@ -138,7 +124,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: {
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -209,9 +194,24 @@ Rectangle {
|
||||
color: root.statusColor
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/open-in-code.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
hoverEnabled: true
|
||||
onClicked: root.openInEditor(editData.edit_id)
|
||||
|
||||
QoAToolTip {
|
||||
visible: parent.hovered
|
||||
delay: 500
|
||||
text: qsTr("Open file in editor and navigate to changes")
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: headerText
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
var modeText = root.oldContent.length > 0 ? qsTr("Replace") : qsTr("Append")
|
||||
if (root.oldContent.length > 0) {
|
||||
@@ -239,6 +239,19 @@ Rectangle {
|
||||
color: palette.mid
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: actionButtons
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
visible: !root.isPending
|
||||
Layout.preferredWidth: badgeText.width + 12
|
||||
@@ -255,31 +268,6 @@ Rectangle {
|
||||
color: root.isArchived ? Qt.rgba(0.6, 0.6, 0.6, 1.0) : palette.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actionButtons
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
QoAButton {
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
hoverEnabled: true
|
||||
onClicked: root.openInEditor(editData.edit_id)
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Open file in editor and navigate to changes")
|
||||
ToolTip.delay: 500
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
icon {
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import Qt.labs.platform as Platform
|
||||
@@ -29,8 +14,6 @@ TextEdit {
|
||||
selectionColor: palette.highlight
|
||||
color: palette.text
|
||||
|
||||
onLinkActivated: (link) => Qt.openUrlExternally(link)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import Qt.labs.platform as Platform
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import Qt.labs.platform as Platform
|
||||
@@ -1,30 +1,17 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.platform as Platform
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
|
||||
property alias attachedFilesModel: attachRepeater.model
|
||||
property color accentColor: palette.mid
|
||||
property string iconPath
|
||||
@@ -36,25 +23,76 @@ Flow {
|
||||
rightPadding: 5
|
||||
topPadding: attachRepeater.model.length > 0 ? 2 : 0
|
||||
bottomPadding: attachRepeater.model.length > 0 ? 2 : 0
|
||||
|
||||
|
||||
Repeater {
|
||||
id: attachRepeater
|
||||
|
||||
delegate: Rectangle {
|
||||
|
||||
delegate: FileItem {
|
||||
id: fileItem
|
||||
|
||||
required property int index
|
||||
required property string modelData
|
||||
|
||||
|
||||
filePath: modelData
|
||||
|
||||
height: 30
|
||||
width: contentRow.width + 10
|
||||
radius: 4
|
||||
color: palette.button
|
||||
border.width: 1
|
||||
border.color: mouse.hovered ? palette.highlight : root.accentColor
|
||||
|
||||
HoverHandler {
|
||||
id: mouse
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 4
|
||||
color: palette.button
|
||||
border.width: 1
|
||||
border.color: mouse.containsMouse ? palette.highlight : root.accentColor
|
||||
}
|
||||
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
contextMenu.open()
|
||||
} else if (mouse.button === Qt.MiddleButton ||
|
||||
(mouse.button === Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier))) {
|
||||
root.removeFileFromListByIndex(fileItem.index)
|
||||
} else if (mouse.modifiers & Qt.ShiftModifier) {
|
||||
fileItem.openFileInExternalEditor()
|
||||
} else {
|
||||
fileItem.openFileInEditor()
|
||||
}
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: mouse.containsMouse
|
||||
delay: 500
|
||||
text: "Click: Open in Qt Creator\nShift+Click: Open in external editor\nCtrl+Click / Middle Click: Remove"
|
||||
}
|
||||
}
|
||||
|
||||
Platform.Menu {
|
||||
id: contextMenu
|
||||
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Open in Qt Creator")
|
||||
onTriggered: fileItem.openFileInEditor()
|
||||
}
|
||||
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Open in External Editor")
|
||||
onTriggered: fileItem.openFileInExternalEditor()
|
||||
}
|
||||
|
||||
Platform.MenuSeparator {}
|
||||
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Remove")
|
||||
onTriggered: root.removeFileFromListByIndex(fileItem.index)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: contentRow
|
||||
|
||||
@@ -71,31 +109,31 @@ Flow {
|
||||
sourceSize.width: 8
|
||||
sourceSize.height: 15
|
||||
}
|
||||
|
||||
|
||||
Text {
|
||||
id: fileNameText
|
||||
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: palette.buttonText
|
||||
|
||||
|
||||
text: {
|
||||
const parts = modelData.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
MouseArea {
|
||||
id: closeButton
|
||||
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: closeIcon.width + 5
|
||||
height: closeButton.width + 5
|
||||
|
||||
|
||||
onClicked: root.removeFileFromListByIndex(index)
|
||||
|
||||
|
||||
Image {
|
||||
id: closeIcon
|
||||
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/close-dark.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/close-light.svg"
|
||||
190
ChatView/qml/controls/BottomBar.qml
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias sendButton: sendButtonId
|
||||
property alias syncOpenFiles: syncOpenFilesId
|
||||
property alias attachFiles: attachFilesId
|
||||
property alias attachImages: attachImagesId
|
||||
property alias linkFiles: linkFilesId
|
||||
property alias compressButton: compressButtonId
|
||||
property alias cancelCompressButton: cancelCompressButtonId
|
||||
|
||||
property bool isCompressing: false
|
||||
property bool isProcessing: false
|
||||
property alias sendButtonTooltip: sendButtonTooltipId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
Qt.lighter(palette.window, 1.1)
|
||||
|
||||
RowLayout {
|
||||
id: bottomBar
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 5
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: attachFilesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/attach-file-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: attachFilesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Attach file to message")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: attachImagesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/image-dark.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: attachImagesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Attach image to message")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: linkFilesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: linkFilesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Link file to context")
|
||||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: syncOpenFilesId
|
||||
|
||||
text: qsTr("Sync open files")
|
||||
|
||||
QoAToolTip {
|
||||
visible: syncOpenFilesId.hovered
|
||||
text: qsTr("Automatically synchronize currently opened files with the model context")
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Row {
|
||||
id: compressingRow
|
||||
|
||||
visible: root.isCompressing
|
||||
spacing: 6
|
||||
|
||||
QoABusyIndicator {
|
||||
id: compressBusyIndicator
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
running: root.isCompressing
|
||||
width: 16
|
||||
height: 16
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Compressing...")
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: palette.text
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: cancelCompressButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qsTr("Cancel")
|
||||
|
||||
QoAToolTip {
|
||||
visible: cancelCompressButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Cancel compression")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: compressButtonId
|
||||
|
||||
visible: !root.isCompressing
|
||||
text: qsTr("Compress")
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/compress-icon.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: compressButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Compress chat (create summarized copy using LLM)")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: sendButtonId
|
||||
|
||||
leftPadding: root.isProcessing ? 22 : 4
|
||||
|
||||
icon {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoABusyIndicator {
|
||||
id: sendBusyIndicator
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 14
|
||||
height: 14
|
||||
running: root.isProcessing
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
id: sendButtonTooltipId
|
||||
|
||||
visible: sendButtonId.hovered
|
||||
delay: 250
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
543
ChatView/qml/controls/ContextViewer.qml
Normal file
@@ -0,0 +1,543 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls.Basic as QQC
|
||||
|
||||
import UIControls
|
||||
import ChatView
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
property string baseSystemPrompt
|
||||
property string currentAgentRole
|
||||
property string currentAgentRoleDescription
|
||||
property string currentAgentRoleSystemPrompt
|
||||
property var activeRules
|
||||
property int activeRulesCount
|
||||
property string selectedRuleContent
|
||||
|
||||
signal openSettings()
|
||||
signal openAgentRolesSettings()
|
||||
signal openRulesFolder()
|
||||
signal refreshRules()
|
||||
signal ruleSelected(int index)
|
||||
|
||||
modal: true
|
||||
focus: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: palette.window
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 4
|
||||
}
|
||||
|
||||
ChatUtils {
|
||||
id: utils
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: qsTr("Chat Context")
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: root.refreshRules()
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Close")
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: palette.mid
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: mainFlickable
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentHeight: sectionsColumn.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
ColumnLayout {
|
||||
id: sectionsColumn
|
||||
|
||||
width: mainFlickable.width
|
||||
spacing: 8
|
||||
|
||||
CollapsibleSection {
|
||||
id: systemPromptSection
|
||||
|
||||
Layout.fillWidth: true
|
||||
title: qsTr("Base System Prompt")
|
||||
badge: root.baseSystemPrompt.length > 0 ? qsTr("Active") : qsTr("Empty")
|
||||
badgeColor: root.baseSystemPrompt.length > 0 ? Qt.rgba(0.2, 0.6, 0.3, 1.0) : palette.mid
|
||||
|
||||
sectionContent: ColumnLayout {
|
||||
spacing: 5
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(Math.max(systemPromptText.implicitHeight + 16, 50), 200)
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
Flickable {
|
||||
id: systemPromptFlickable
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentHeight: systemPromptText.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: systemPromptText
|
||||
|
||||
width: systemPromptFlickable.width
|
||||
text: root.baseSystemPrompt.length > 0 ? root.baseSystemPrompt : qsTr("No system prompt configured")
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: root.baseSystemPrompt.length > 0 ? palette.text : palette.mid
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: systemPromptFlickable.contentHeight > systemPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Copy")
|
||||
enabled: root.baseSystemPrompt.length > 0
|
||||
onClicked: utils.copyToClipboard(root.baseSystemPrompt)
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Edit in Settings")
|
||||
onClicked: {
|
||||
root.openSettings()
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection {
|
||||
id: agentRoleSection
|
||||
|
||||
Layout.fillWidth: true
|
||||
title: qsTr("Agent Role")
|
||||
badge: root.currentAgentRole
|
||||
badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid
|
||||
|
||||
sectionContent: ColumnLayout {
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: root.currentAgentRoleDescription
|
||||
font.pixelSize: 11
|
||||
font.italic: true
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
visible: root.currentAgentRoleDescription.length > 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200)
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
visible: root.currentAgentRoleSystemPrompt.length > 0
|
||||
|
||||
Flickable {
|
||||
id: agentPromptFlickable
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentHeight: agentPromptText.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: agentPromptText
|
||||
|
||||
width: agentPromptFlickable.width
|
||||
text: root.currentAgentRoleSystemPrompt
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: palette.text
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("No role selected. Using base system prompt only.")
|
||||
font.pixelSize: 11
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
visible: root.currentAgentRoleSystemPrompt.length === 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Copy")
|
||||
enabled: root.currentAgentRoleSystemPrompt.length > 0
|
||||
onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt)
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Manage Roles")
|
||||
onClicked: {
|
||||
root.openAgentRolesSettings()
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection {
|
||||
id: projectRulesSection
|
||||
|
||||
Layout.fillWidth: true
|
||||
title: qsTr("Project Rules")
|
||||
badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None")
|
||||
badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid
|
||||
|
||||
sectionContent: ColumnLayout {
|
||||
spacing: 8
|
||||
|
||||
SplitView {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 220
|
||||
orientation: Qt.Horizontal
|
||||
visible: root.activeRulesCount > 0
|
||||
|
||||
Rectangle {
|
||||
SplitView.minimumWidth: 120
|
||||
SplitView.preferredWidth: 180
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: qsTr("Rules (%1)").arg(rulesList.count)
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: rulesList
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
model: root.activeRules
|
||||
currentIndex: 0
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: ListView.view.width
|
||||
height: ruleItemContent.implicitHeight + 8
|
||||
highlighted: ListView.isCurrentItem
|
||||
|
||||
background: Rectangle {
|
||||
color: {
|
||||
if (parent.highlighted)
|
||||
return palette.highlight
|
||||
if (parent.hovered)
|
||||
return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05))
|
||||
return "transparent"
|
||||
}
|
||||
radius: 2
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
id: ruleItemContent
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: modelData.fileName
|
||||
font.pixelSize: 10
|
||||
color: parent.parent.highlighted ? palette.highlightedText : palette.text
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.category
|
||||
font.pixelSize: 9
|
||||
color: parent.parent.highlighted ? palette.highlightedText : palette.mid
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
rulesList.currentIndex = index
|
||||
root.ruleSelected(index)
|
||||
}
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
SplitView.fillWidth: true
|
||||
SplitView.minimumWidth: 200
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 5
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: qsTr("Content")
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Copy")
|
||||
enabled: root.selectedRuleContent.length > 0
|
||||
onClicked: utils.copyToClipboard(root.selectedRuleContent)
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: ruleContentFlickable
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentHeight: ruleContentArea.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: ruleContentArea
|
||||
|
||||
width: ruleContentFlickable.width
|
||||
text: root.selectedRuleContent
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.WordWrap
|
||||
selectionColor: palette.highlight
|
||||
color: palette.text
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/")
|
||||
font.pixelSize: 11
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
visible: root.activeRulesCount === 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Open Rules Folder")
|
||||
onClicked: root.openRulesFolder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: mainFlickable.contentHeight > mainFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: palette.mid
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files")
|
||||
font.pixelSize: 9
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
component CollapsibleSection: ColumnLayout {
|
||||
id: sectionRoot
|
||||
|
||||
property string title
|
||||
property string badge
|
||||
property color badgeColor: palette.mid
|
||||
property Component sectionContent: null
|
||||
property bool expanded: false
|
||||
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
color: sectionMouseArea.containsMouse ? Qt.tint(palette.button, Qt.rgba(0, 0, 0, 0.05)) : palette.button
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
MouseArea {
|
||||
id: sectionMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: sectionRoot.expanded = !sectionRoot.expanded
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: sectionRoot.expanded ? "▼" : "▶"
|
||||
font.pixelSize: 10
|
||||
color: palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
text: sectionRoot.title
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
implicitWidth: badgeText.implicitWidth + 12
|
||||
implicitHeight: 18
|
||||
color: sectionRoot.badgeColor
|
||||
radius: 3
|
||||
|
||||
Text {
|
||||
id: badgeText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: sectionRoot.badge
|
||||
font.pixelSize: 10
|
||||
color: "#FFFFFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 12
|
||||
Layout.topMargin: 8
|
||||
Layout.bottomMargin: 4
|
||||
sourceComponent: sectionRoot.sectionContent
|
||||
visible: sectionRoot.expanded
|
||||
active: sectionRoot.expanded
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
if (root.activeRulesCount > 0) {
|
||||
root.ruleSelected(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -133,12 +118,14 @@ Rectangle {
|
||||
text: root.hasPendingEdits
|
||||
? qsTr("Apply All (%1)").arg(root.pendingEdits + root.rejectedEdits)
|
||||
: qsTr("Reapply All (%1)").arg(root.rejectedEdits)
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: root.hasPendingEdits
|
||||
? qsTr("Apply all pending and rejected edits in this message")
|
||||
: qsTr("Reapply all rejected edits in this message")
|
||||
|
||||
QoAToolTip {
|
||||
visible: applyAllButton.hovered
|
||||
delay: 250
|
||||
text: root.hasPendingEdits
|
||||
? qsTr("Apply all pending and rejected edits in this message")
|
||||
: qsTr("Reapply all rejected edits in this message")
|
||||
}
|
||||
|
||||
onClicked: root.applyAllClicked()
|
||||
}
|
||||
@@ -149,10 +136,12 @@ Rectangle {
|
||||
visible: root.hasAppliedEdits
|
||||
enabled: root.hasAppliedEdits
|
||||
text: qsTr("Undo All (%1)").arg(root.appliedEdits)
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Undo all applied edits in this message")
|
||||
|
||||
QoAToolTip {
|
||||
visible: undoAllButton.hovered
|
||||
delay: 250
|
||||
text: qsTr("Undo all applied edits in this message")
|
||||
}
|
||||
|
||||
onClicked: root.undoAllClicked()
|
||||
}
|
||||
152
ChatView/qml/controls/FileMentionPopup.qml
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
FileMentionItem {
|
||||
id: root
|
||||
|
||||
signal selectionRequested()
|
||||
|
||||
visible: searchResults.length > 0
|
||||
height: Math.min(searchResults.length * 36, 36 * 6) + 2
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
listView.positionViewAtIndex(root.currentIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: background
|
||||
|
||||
anchors.fill: parent
|
||||
color: palette.window
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 4
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: listView
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
model: root.searchResults
|
||||
currentIndex: root.currentIndex
|
||||
clip: true
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: delegateItem
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isProject: modelData.isProject === true
|
||||
readonly property bool isOpen: modelData.isOpen === true
|
||||
readonly property string fileName: {
|
||||
if (isProject)
|
||||
return modelData.projectName
|
||||
const parts = modelData.relativePath.split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
width: listView.width
|
||||
height: 36
|
||||
color: index === root.currentIndex
|
||||
? palette.highlight
|
||||
: (hoverArea.containsMouse
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25)
|
||||
: "transparent")
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: 18
|
||||
Layout.preferredHeight: 18
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 3
|
||||
visible: delegateItem.isProject || delegateItem.isOpen
|
||||
|
||||
color: {
|
||||
if (delegateItem.index === root.currentIndex)
|
||||
return Qt.rgba(palette.highlightedText.r,
|
||||
palette.highlightedText.g,
|
||||
palette.highlightedText.b, 0.2)
|
||||
if (delegateItem.isProject)
|
||||
return Qt.rgba(palette.highlight.r,
|
||||
palette.highlight.g,
|
||||
palette.highlight.b, 0.3)
|
||||
return Qt.rgba(0.2, 0.7, 0.4, 0.3)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: delegateItem.isProject ? "P" : "O"
|
||||
font.bold: true
|
||||
font.pixelSize: 10
|
||||
color: {
|
||||
if (delegateItem.index === root.currentIndex)
|
||||
return palette.highlightedText
|
||||
if (delegateItem.isProject)
|
||||
return palette.highlight
|
||||
return Qt.rgba(0.1, 0.6, 0.3, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 160
|
||||
text: delegateItem.fileName
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? palette.highlightedText
|
||||
: (delegateItem.isProject ? palette.highlight : palette.text)
|
||||
font.bold: true
|
||||
font.italic: delegateItem.isProject
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: delegateItem.isProject
|
||||
? "→"
|
||||
: (delegateItem.modelData.projectName + " / " + delegateItem.modelData.relativePath)
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? (delegateItem.isProject
|
||||
? palette.highlightedText
|
||||
: Qt.rgba(palette.highlightedText.r,
|
||||
palette.highlightedText.g,
|
||||
palette.highlightedText.b, 0.7))
|
||||
: palette.mid
|
||||
font.pixelSize: delegateItem.isProject ? 12 : 11
|
||||
elide: Text.ElideLeft
|
||||
horizontalAlignment: delegateItem.isProject ? Text.AlignLeft : Text.AlignRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hoverArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
root.currentIndex = delegateItem.index
|
||||
root.selectionRequested()
|
||||
}
|
||||
onEntered: root.currentIndex = delegateItem.index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
ChatView/qml/controls/MessageNavigator.qml
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Item {
|
||||
id: nav
|
||||
|
||||
property var chatModel
|
||||
property var entries: []
|
||||
property color dotColor: "#92BD6C"
|
||||
property int currentMessageIndex: -1
|
||||
|
||||
readonly property int dotCount: entries.length
|
||||
readonly property int verticalPadding: 8
|
||||
readonly property int minDotSpacing: 18
|
||||
readonly property real availableHeight: Math.max(0, height - 2 * verticalPadding)
|
||||
readonly property real naturalHeight: dotCount > 1 ? (dotCount - 1) * minDotSpacing : 0
|
||||
readonly property bool needsScrolling: naturalHeight > availableHeight
|
||||
readonly property real contentHeight: needsScrolling
|
||||
? naturalHeight + 2 * verticalPadding
|
||||
: Math.max(height, 2 * verticalPadding)
|
||||
|
||||
signal messageClicked(int messageIndex)
|
||||
|
||||
implicitWidth: 16
|
||||
|
||||
function rebuild() {
|
||||
entries = chatModel ? chatModel.userMessagePreviews(80) : []
|
||||
Qt.callLater(scrollCurrentIntoView)
|
||||
}
|
||||
|
||||
function updateCurrentFromModelIndex(modelIdx) {
|
||||
if (modelIdx < 0) {
|
||||
currentMessageIndex = -1
|
||||
return
|
||||
}
|
||||
let best = -1
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const e = entries[i]
|
||||
if (!e)
|
||||
continue
|
||||
const mi = e.messageIndex
|
||||
if (mi <= modelIdx)
|
||||
best = mi
|
||||
else
|
||||
break
|
||||
}
|
||||
currentMessageIndex = best
|
||||
}
|
||||
|
||||
function uiIndexOf(messageIndex) {
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const e = entries[i]
|
||||
if (e && e.messageIndex === messageIndex)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function dotCenterY(uiIndex) {
|
||||
const count = dotCount
|
||||
if (count <= 1)
|
||||
return contentHeight / 2
|
||||
const spacing = needsScrolling
|
||||
? minDotSpacing
|
||||
: availableHeight / (count - 1)
|
||||
return verticalPadding + spacing * uiIndex
|
||||
}
|
||||
|
||||
function scrollCurrentIntoView() {
|
||||
if (!needsScrolling || currentMessageIndex < 0)
|
||||
return
|
||||
const ui = uiIndexOf(currentMessageIndex)
|
||||
if (ui < 0)
|
||||
return
|
||||
const y = dotCenterY(ui)
|
||||
const margin = 24
|
||||
if (y < flick.contentY + margin)
|
||||
flick.contentY = Math.max(0, y - margin)
|
||||
else if (y > flick.contentY + flick.height - margin)
|
||||
flick.contentY = Math.min(
|
||||
Math.max(0, flick.contentHeight - flick.height),
|
||||
y - flick.height + margin)
|
||||
}
|
||||
|
||||
onChatModelChanged: rebuild()
|
||||
onCurrentMessageIndexChanged: scrollCurrentIntoView()
|
||||
Component.onCompleted: rebuild()
|
||||
|
||||
Connections {
|
||||
target: nav.chatModel
|
||||
ignoreUnknownSignals: true
|
||||
function onRowsInserted() { nav.rebuild() }
|
||||
function onRowsRemoved() { nav.rebuild() }
|
||||
function onModelReset() { nav.rebuild() }
|
||||
function onModelReseted() { nav.rebuild() }
|
||||
function onDataChanged() { nav.rebuild() }
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: nav.contentHeight
|
||||
interactive: nav.needsScrolling
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Rectangle {
|
||||
id: spine
|
||||
|
||||
visible: nav.dotCount > 1
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: nav.verticalPadding
|
||||
width: 1
|
||||
height: Math.max(0, flick.contentHeight - 2 * nav.verticalPadding)
|
||||
color: palette.mid
|
||||
opacity: 0.4
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: nav.entries
|
||||
|
||||
delegate: Item {
|
||||
id: dotItem
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property int msgIndex: modelData && modelData.messageIndex !== undefined
|
||||
? modelData.messageIndex : -1
|
||||
readonly property string preview: modelData && modelData.preview !== undefined
|
||||
? modelData.preview : ""
|
||||
readonly property bool isCurrent: nav.currentMessageIndex === msgIndex
|
||||
|
||||
width: 16
|
||||
height: 14
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: nav.dotCenterY(index) - height / 2
|
||||
|
||||
Rectangle {
|
||||
id: dot
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: dotItem.isCurrent ? 11 : (dotArea.containsMouse ? 10 : 7)
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: dotArea.containsMouse
|
||||
? Qt.lighter(nav.dotColor, 1.2)
|
||||
: nav.dotColor
|
||||
border.color: dotItem.isCurrent
|
||||
? Qt.darker(nav.dotColor, 1.7)
|
||||
: Qt.darker(nav.dotColor, 1.4)
|
||||
border.width: dotItem.isCurrent ? 2 : 1
|
||||
opacity: dotItem.isCurrent || dotArea.containsMouse ? 1.0 : 0.55
|
||||
|
||||
Behavior on width { NumberAnimation { duration: 120 } }
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: dotArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: nav.messageClicked(dotItem.msgIndex)
|
||||
|
||||
QoAToolTip {
|
||||
visible: dotArea.containsMouse
|
||||
delay: 350
|
||||
text: dotItem.preview.length > 0
|
||||
? qsTr("#%1 · %2").arg(dotItem.index + 1).arg(dotItem.preview)
|
||||
: qsTr("Jump to message #%1").arg(dotItem.index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
126
ChatView/qml/controls/SkillCommandPopup.qml
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
276
ChatView/qml/controls/SplitDropZone.qml
Normal file
@@ -0,0 +1,276 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal filesDroppedToAttach(var urlStrings)
|
||||
signal filesDroppedToLink(var urlStrings)
|
||||
|
||||
property string activeZone: ""
|
||||
property int filesCount: 0
|
||||
property bool isDragActive: false
|
||||
|
||||
Item {
|
||||
id: splitDropOverlay
|
||||
|
||||
anchors.fill: parent
|
||||
visible: false
|
||||
z: 999
|
||||
opacity: 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
top: parent.top
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
topMargin: 30
|
||||
}
|
||||
width: fileCountText.width + 40
|
||||
height: 50
|
||||
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
|
||||
radius: 25
|
||||
visible: root.filesCount > 0
|
||||
|
||||
Text {
|
||||
id: fileCountText
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("%n file(s) to drop", "", root.filesCount)
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
color: palette.highlightedText
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: leftZone
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
color: root.activeZone === "left"
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.activeZone === "left" ? 3 : 2
|
||||
border.color: root.activeZone === "left"
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Attach")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Images & Text Files")
|
||||
font.pixelSize: 14
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("(for one-time use)")
|
||||
font.pixelSize: 12
|
||||
font.italic: true
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.width { NumberAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: rightZone
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
color: root.activeZone === "right"
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.activeZone === "right" ? 3 : 2
|
||||
border.color: root.activeZone === "right"
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("LINK")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Text Files")
|
||||
font.pixelSize: 14
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("(added to context)")
|
||||
font.pixelSize: 12
|
||||
font.italic: true
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.width { NumberAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: 2
|
||||
color: palette.mid
|
||||
opacity: 0.4
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: leftDropArea
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
root.activeZone = "left"
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rightDropArea
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
root.activeZone = "right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: globalDropArea
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
onEntered: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.isDragActive = true
|
||||
root.filesCount = drag.urls.length
|
||||
splitDropOverlay.visible = true
|
||||
splitDropOverlay.opacity = 1
|
||||
root.activeZone = ""
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
splitDropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (!root.isDragActive) {
|
||||
splitDropOverlay.visible = false
|
||||
root.activeZone = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onPositionChanged: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
|
||||
}
|
||||
}
|
||||
|
||||
onDropped: (drop) => {
|
||||
const targetZone = root.activeZone
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
splitDropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
splitDropOverlay.visible = false
|
||||
root.activeZone = ""
|
||||
})
|
||||
|
||||
if (!drop.hasUrls || drop.urls.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
var urlStrings = []
|
||||
for (var i = 0; i < drop.urls.length; i++) {
|
||||
var urlString = drop.urls[i].toString()
|
||||
if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
|
||||
urlStrings.push(urlString)
|
||||
}
|
||||
}
|
||||
|
||||
if (urlStrings.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
drop.accept(Qt.CopyAction)
|
||||
|
||||
if (targetZone === "right") {
|
||||
root.filesDroppedToLink(urlStrings)
|
||||
} else {
|
||||
root.filesDroppedToAttach(urlStrings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
|
||||
303
ChatView/qml/controls/TopBar.qml
Normal file
@@ -0,0 +1,303 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import ChatView
|
||||
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 settingsButton: settingsButtonId
|
||||
property alias agentSelector: agentSelectorId
|
||||
property alias roleSelector: roleSelectorId
|
||||
property alias relocateTooltip: relocateTooltipId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
Qt.lighter(palette.window, 1.1)
|
||||
|
||||
Flow {
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
margins: 5
|
||||
}
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
id: firstRow
|
||||
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: pinButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checkable: true
|
||||
|
||||
icon {
|
||||
source: checked ? "qrc:/qt/qml/ChatView/icons/window-lock.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/window-unlock.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: pinButtonId.hovered
|
||||
delay: 250
|
||||
text: pinButtonId.checked ? qsTr("Unpin chat window")
|
||||
: 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
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
id: relocateTooltipId
|
||||
|
||||
visible: relocateButtonId.hovered
|
||||
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
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: clearButtonId.hovered
|
||||
delay: 250
|
||||
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
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: newChatButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Open new chat in a new tab")
|
||||
}
|
||||
}
|
||||
|
||||
QoAComboBox {
|
||||
id: agentSelectorId
|
||||
|
||||
implicitHeight: 25
|
||||
|
||||
model: []
|
||||
currentIndex: 0
|
||||
|
||||
QoAToolTip {
|
||||
visible: agentSelectorId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Select chat agent (provider and model come from the agent)")
|
||||
}
|
||||
}
|
||||
|
||||
QoAComboBox {
|
||||
id: roleSelectorId
|
||||
|
||||
implicitHeight: 25
|
||||
|
||||
model: []
|
||||
currentIndex: 0
|
||||
|
||||
QoAToolTip {
|
||||
visible: roleSelectorId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Select the role (system prompt) for the chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: settingsButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/settings-icon.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: settingsButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Open Chat Assistant Settings")
|
||||
}
|
||||
}
|
||||
|
||||
QoASeparator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Item {
|
||||
height: firstRow.height
|
||||
width: recentPathId.width
|
||||
|
||||
Text {
|
||||
id: recentPathId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.min(implicitWidth, root.width)
|
||||
elide: Text.ElideMiddle
|
||||
color: palette.text
|
||||
font.pixelSize: 12
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
QoAToolTip {
|
||||
visible: parent.containsMouse && recentPathId.text.length > 0
|
||||
text: recentPathId.text
|
||||
delay: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: secondRow
|
||||
|
||||
Layout.preferredWidth: root.width
|
||||
Layout.preferredHeight: firstRow.height
|
||||
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: saveButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/save-chat-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: saveButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Save chat to *.json file")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: loadButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/load-chat-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: loadButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Load chat from *.json file")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: openChatHistoryId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/file-in-system.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: openChatHistoryId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Show in system")
|
||||
}
|
||||
}
|
||||
|
||||
QoASeparator {}
|
||||
|
||||
QoAButton {
|
||||
id: contextButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/context-icon.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: contextButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("View chat context (system prompt, role, rules)")
|
||||
}
|
||||
}
|
||||
|
||||
Badge {
|
||||
id: tokensBadgeId
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias sendButton: sendButtonId
|
||||
property alias syncOpenFiles: syncOpenFilesId
|
||||
property alias attachFiles: attachFilesId
|
||||
property alias linkFiles: linkFilesId
|
||||
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
Qt.lighter(palette.window, 1.1)
|
||||
|
||||
RowLayout {
|
||||
id: bottomBar
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 5
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: sendButtonId
|
||||
|
||||
icon {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: attachFilesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/attach-file-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Attach file to message")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: linkFilesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Link file to context")
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: syncOpenFilesId
|
||||
|
||||
text: qsTr("Sync open files")
|
||||
|
||||
ToolTip.visible: syncOpenFilesId.hovered
|
||||
ToolTip.text: qsTr("Automatically synchronize currently opened files with the model context")
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls.Basic as QQC
|
||||
|
||||
import UIControls
|
||||
import ChatView
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
property var activeRules
|
||||
|
||||
property alias rulesCurrentIndex: rulesList.currentIndex
|
||||
property alias ruleContentAreaText: ruleContentArea.text
|
||||
|
||||
signal refreshRules()
|
||||
signal openRulesFolder()
|
||||
|
||||
modal: true
|
||||
focus: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: palette.window
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 4
|
||||
}
|
||||
|
||||
ChatUtils {
|
||||
id: utils
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: qsTr("Active Project Rules")
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Open Folder")
|
||||
onClicked: root.openRulesFolder()
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: root.refreshRules()
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Close")
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: palette.mid
|
||||
}
|
||||
|
||||
SplitView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
orientation: Qt.Horizontal
|
||||
|
||||
Rectangle {
|
||||
SplitView.minimumWidth: 200
|
||||
SplitView.preferredWidth: parent.width * 0.3
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: qsTr("Rules Files (%1)").arg(rulesList.count)
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: rulesList
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
model: root.activeRules
|
||||
currentIndex: 0
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: ListView.view.width
|
||||
highlighted: ListView.isCurrentItem
|
||||
|
||||
background: Rectangle {
|
||||
color: {
|
||||
if (parent.highlighted) {
|
||||
return palette.highlight
|
||||
} else if (parent.hovered) {
|
||||
return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05))
|
||||
}
|
||||
return "transparent"
|
||||
}
|
||||
radius: 2
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: modelData.fileName
|
||||
font.pixelSize: 11
|
||||
color: parent.parent.highlighted ? palette.highlightedText : palette.text
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Category: %1").arg(modelData.category)
|
||||
font.pixelSize: 9
|
||||
color: parent.parent.highlighted ? palette.highlightedText : palette.mid
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
rulesList.currentIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.vertical: QQC.ScrollBar {
|
||||
id: scroll
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: rulesList.count === 0
|
||||
text: qsTr("No rules found.\nCreate .md files in:\n.qodeassist/rules/common/\n.qodeassist/rules/chat/")
|
||||
font.pixelSize: 10
|
||||
color: palette.mid
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
SplitView.fillWidth: true
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 5
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: qsTr("Content")
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Copy")
|
||||
enabled: ruleContentArea.text.length > 0
|
||||
onClicked: utils.copyToClipboard(ruleContentArea.text)
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
|
||||
TextEdit {
|
||||
id: ruleContentArea
|
||||
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.WordWrap
|
||||
selectionColor: palette.highlight
|
||||
color: palette.text
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Rules are loaded from .qodeassist/rules/ directory in your project.\n" +
|
||||
"Common rules apply to all contexts, chat rules apply only to chat assistant.")
|
||||
font.pixelSize: 9
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias saveButton: saveButtonId
|
||||
property alias loadButton: loadButtonId
|
||||
property alias clearButton: clearButtonId
|
||||
property alias tokensBadge: tokensBadgeId
|
||||
property alias recentPath: recentPathId
|
||||
property alias openChatHistory: openChatHistoryId
|
||||
property alias pinButton: pinButtonId
|
||||
property alias rulesButton: rulesButtonId
|
||||
property alias agentModeSwitch: agentModeSwitchId
|
||||
property alias thinkingMode: thinkingModeId
|
||||
property alias activeRulesCount: activeRulesCountId.text
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
Qt.lighter(palette.window, 1.1)
|
||||
|
||||
Flow {
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
margins: 5
|
||||
}
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
height: agentModeSwitchId.height
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: pinButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checkable: true
|
||||
|
||||
icon {
|
||||
source: checked ? "qrc:/qt/qml/ChatView/icons/window-lock.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/window-unlock.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: checked ? qsTr("Unpin chat window")
|
||||
: qsTr("Pin chat window to the top")
|
||||
}
|
||||
|
||||
QoATextSlider {
|
||||
id: agentModeSwitchId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
leftText: "chat"
|
||||
rightText: "AI Agent"
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: {
|
||||
if (!agentModeSwitchId.enabled) {
|
||||
return qsTr("Tools are disabled in General Settings")
|
||||
}
|
||||
return checked
|
||||
? qsTr("Agent Mode: AI can use tools to read files, search project, and build code")
|
||||
: qsTr("Chat Mode: Simple conversation without tool access")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: thinkingModeId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
checkable: true
|
||||
opacity: enabled ? 1.0 : 0.2
|
||||
|
||||
icon {
|
||||
source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: enabled ? (checked ? qsTr("Thinking Mode enabled (Check model list support it)")
|
||||
: qsTr("Thinking Mode disabled"))
|
||||
: qsTr("Thinking Mode is not available for this provider")
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
height: agentModeSwitchId.height
|
||||
width: recentPathId.width
|
||||
|
||||
Text {
|
||||
id: recentPathId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.min(implicitWidth, root.width)
|
||||
elide: Text.ElideMiddle
|
||||
color: palette.text
|
||||
font.pixelSize: 12
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: recentPathId.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.preferredWidth: root.width
|
||||
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: saveButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/save-chat-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Save chat to *.json file")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: loadButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/load-chat-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Load chat from *.json file")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: openChatHistoryId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/file-in-system.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Show in system")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: rulesButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/rules-icon.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
text: " "
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: root.activeRulesCount > 0
|
||||
? qsTr("View active project rules (%1)").arg(root.activeRulesCount)
|
||||
: qsTr("View active project rules (no rules found)")
|
||||
|
||||
Text {
|
||||
id: activeRulesCountId
|
||||
|
||||
anchors {
|
||||
bottom: parent.bottom
|
||||
bottomMargin: 2
|
||||
right: parent.right
|
||||
rightMargin: 4
|
||||
}
|
||||
|
||||
color: palette.text
|
||||
font.pixelSize: 10
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Badge {
|
||||
id: tokensBadgeId
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,7 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
* Copyright (C) 2025 Povilas Kanapickas <povilas@radix.lt>
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// Copyright (C) 2025 Povilas Kanapickas <povilas@radix.lt>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "CodeHandler.hpp"
|
||||
#include <settings/CodeCompletionSettings.hpp>
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "ConfigurationManager.hpp"
|
||||
|
||||
#include <settings/ButtonAspect.hpp>
|
||||
#include <QTimer>
|
||||
|
||||
#include "QodeAssisttr.h"
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
ConfigurationManager &ConfigurationManager::instance()
|
||||
{
|
||||
static ConfigurationManager instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void ConfigurationManager::init()
|
||||
{
|
||||
setupConnections();
|
||||
updateAllTemplateDescriptions();
|
||||
checkAllTemplate();
|
||||
}
|
||||
|
||||
void ConfigurationManager::updateTemplateDescription(const Utils::StringAspect &templateAspect)
|
||||
{
|
||||
LLMCore::PromptTemplate *templ = m_templateManger.getFimTemplateByName(templateAspect.value());
|
||||
|
||||
if (!templ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (&templateAspect == &m_generalSettings.ccTemplate) {
|
||||
m_generalSettings.ccTemplateDescription.setValue(templ->description());
|
||||
} else if (&templateAspect == &m_generalSettings.caTemplate) {
|
||||
m_generalSettings.caTemplateDescription.setValue(templ->description());
|
||||
} else if (&templateAspect == &m_generalSettings.qrTemplate) {
|
||||
m_generalSettings.qrTemplateDescription.setValue(templ->description());
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigurationManager::updateAllTemplateDescriptions()
|
||||
{
|
||||
updateTemplateDescription(m_generalSettings.ccTemplate);
|
||||
updateTemplateDescription(m_generalSettings.caTemplate);
|
||||
updateTemplateDescription(m_generalSettings.qrTemplate);
|
||||
}
|
||||
|
||||
void ConfigurationManager::checkTemplate(const Utils::StringAspect &templateAspect)
|
||||
{
|
||||
LLMCore::PromptTemplate *templ = m_templateManger.getFimTemplateByName(templateAspect.value());
|
||||
|
||||
if (templ->name() == templateAspect.value())
|
||||
return;
|
||||
|
||||
if (&templateAspect == &m_generalSettings.ccTemplate) {
|
||||
m_generalSettings.ccTemplate.setValue(templ->name());
|
||||
} else if (&templateAspect == &m_generalSettings.caTemplate) {
|
||||
m_generalSettings.caTemplate.setValue(templ->name());
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigurationManager::checkAllTemplate()
|
||||
{
|
||||
checkTemplate(m_generalSettings.ccTemplate);
|
||||
checkTemplate(m_generalSettings.caTemplate);
|
||||
}
|
||||
|
||||
ConfigurationManager::ConfigurationManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_generalSettings(Settings::generalSettings())
|
||||
, m_providersManager(LLMCore::ProvidersManager::instance())
|
||||
, m_templateManger(LLMCore::PromptTemplateManager::instance())
|
||||
{}
|
||||
|
||||
void ConfigurationManager::setupConnections()
|
||||
{
|
||||
using Config = ConfigurationManager;
|
||||
using Button = ButtonAspect;
|
||||
|
||||
connect(&m_generalSettings.ccSelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.caSelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.qrSelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.ccSelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(&m_generalSettings.caSelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(&m_generalSettings.qrSelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(&m_generalSettings.ccSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
connect(&m_generalSettings.caSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
connect(&m_generalSettings.qrSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
connect(&m_generalSettings.ccSetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
connect(&m_generalSettings.caSetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
connect(&m_generalSettings.qrSetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
|
||||
connect(
|
||||
&m_generalSettings.ccPreset1SelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.ccPreset1SetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
connect(&m_generalSettings.ccPreset1SelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(
|
||||
&m_generalSettings.ccPreset1SelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
|
||||
connect(&m_generalSettings.ccTemplate, &Utils::StringAspect::changed, this, [this]() {
|
||||
updateTemplateDescription(m_generalSettings.ccTemplate);
|
||||
});
|
||||
|
||||
connect(&m_generalSettings.caTemplate, &Utils::StringAspect::changed, this, [this]() {
|
||||
updateTemplateDescription(m_generalSettings.caTemplate);
|
||||
});
|
||||
|
||||
connect(&m_generalSettings.qrTemplate, &Utils::StringAspect::changed, this, [this]() {
|
||||
updateTemplateDescription(m_generalSettings.qrTemplate);
|
||||
});
|
||||
}
|
||||
|
||||
void ConfigurationManager::selectProvider()
|
||||
{
|
||||
const auto providersList = m_providersManager.providersNames();
|
||||
|
||||
auto *settingsButton = qobject_cast<ButtonAspect *>(sender());
|
||||
if (!settingsButton)
|
||||
return;
|
||||
|
||||
auto &targetSettings = (settingsButton == &m_generalSettings.ccSelectProvider)
|
||||
? m_generalSettings.ccProvider
|
||||
: settingsButton == &m_generalSettings.ccPreset1SelectProvider
|
||||
? m_generalSettings.ccPreset1Provider
|
||||
: settingsButton == &m_generalSettings.qrSelectProvider
|
||||
? m_generalSettings.qrProvider
|
||||
: m_generalSettings.caProvider;
|
||||
|
||||
QTimer::singleShot(0, this, [this, providersList, &targetSettings] {
|
||||
m_generalSettings.showSelectionDialog(
|
||||
providersList, targetSettings, Tr::tr("Select LLM Provider"), Tr::tr("Providers:"));
|
||||
});
|
||||
}
|
||||
|
||||
void ConfigurationManager::selectModel()
|
||||
{
|
||||
auto *settingsButton = qobject_cast<ButtonAspect *>(sender());
|
||||
if (!settingsButton)
|
||||
return;
|
||||
|
||||
const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectModel);
|
||||
const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectModel);
|
||||
const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectModel);
|
||||
|
||||
const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue()
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue()
|
||||
: isQuickRefactor ? m_generalSettings.qrProvider.volatileValue()
|
||||
: m_generalSettings.caProvider.volatileValue();
|
||||
|
||||
const auto providerUrl = isCodeCompletion ? m_generalSettings.ccUrl.volatileValue()
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Url.volatileValue()
|
||||
: isQuickRefactor ? m_generalSettings.qrUrl.volatileValue()
|
||||
: m_generalSettings.caUrl.volatileValue();
|
||||
|
||||
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccModel
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Model
|
||||
: isQuickRefactor ? m_generalSettings.qrModel
|
||||
: m_generalSettings.caModel;
|
||||
|
||||
if (auto provider = m_providersManager.getProviderByName(providerName)) {
|
||||
if (!provider->supportsModelListing()) {
|
||||
m_generalSettings.showModelsNotSupportedDialog(targetSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto modelList = provider->getInstalledModels(providerUrl);
|
||||
|
||||
if (modelList.isEmpty()) {
|
||||
m_generalSettings.showModelsNotFoundDialog(targetSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
QTimer::singleShot(0, &m_generalSettings, [this, modelList, &targetSettings]() {
|
||||
m_generalSettings.showSelectionDialog(
|
||||
modelList, targetSettings, Tr::tr("Select LLM Model"), Tr::tr("Models:"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigurationManager::selectTemplate()
|
||||
{
|
||||
auto *settingsButton = qobject_cast<ButtonAspect *>(sender());
|
||||
if (!settingsButton)
|
||||
return;
|
||||
|
||||
const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectTemplate);
|
||||
const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectTemplate);
|
||||
const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectTemplate);
|
||||
const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue()
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue()
|
||||
: isQuickRefactor ? m_generalSettings.qrProvider.volatileValue()
|
||||
: m_generalSettings.caProvider.volatileValue();
|
||||
auto providerID = m_providersManager.getProviderByName(providerName)->providerID();
|
||||
|
||||
const auto templateList = isCodeCompletion || isPreset1
|
||||
? m_templateManger.getFimTemplatesForProvider(providerID)
|
||||
: m_templateManger.getChatTemplatesForProvider(providerID);
|
||||
|
||||
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccTemplate
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Template
|
||||
: isQuickRefactor ? m_generalSettings.qrTemplate
|
||||
: m_generalSettings.caTemplate;
|
||||
|
||||
QTimer::singleShot(0, &m_generalSettings, [this, templateList, &targetSettings]() {
|
||||
m_generalSettings.showSelectionDialog(
|
||||
templateList, targetSettings, Tr::tr("Select Template"), Tr::tr("Templates:"));
|
||||
});
|
||||
}
|
||||
|
||||
void ConfigurationManager::selectUrl()
|
||||
{
|
||||
auto *settingsButton = qobject_cast<ButtonAspect *>(sender());
|
||||
if (!settingsButton)
|
||||
return;
|
||||
|
||||
QStringList urls;
|
||||
for (const auto &name : m_providersManager.providersNames()) {
|
||||
const auto url = m_providersManager.getProviderByName(name)->url();
|
||||
if (!urls.contains(url))
|
||||
urls.append(url);
|
||||
}
|
||||
|
||||
auto &targetSettings = (settingsButton == &m_generalSettings.ccSetUrl) ? m_generalSettings.ccUrl
|
||||
: settingsButton == &m_generalSettings.ccPreset1SetUrl
|
||||
? m_generalSettings.ccPreset1Url
|
||||
: settingsButton == &m_generalSettings.qrSetUrl
|
||||
? m_generalSettings.qrUrl
|
||||
: m_generalSettings.caUrl;
|
||||
|
||||
QTimer::singleShot(0, &m_generalSettings, [this, urls, &targetSettings]() {
|
||||
m_generalSettings.showUrlSelectionDialog(targetSettings, urls);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
|
||||
#include "llmcore/PromptTemplateManager.hpp"
|
||||
#include "llmcore/ProvidersManager.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
class ConfigurationManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static ConfigurationManager &instance();
|
||||
|
||||
void init();
|
||||
|
||||
void updateTemplateDescription(const Utils::StringAspect &templateAspect);
|
||||
void updateAllTemplateDescriptions();
|
||||
void checkTemplate(const Utils::StringAspect &templateAspect);
|
||||
void checkAllTemplate();
|
||||
|
||||
public slots:
|
||||
void selectProvider();
|
||||
void selectModel();
|
||||
void selectTemplate();
|
||||
void selectUrl();
|
||||
|
||||
private:
|
||||
explicit ConfigurationManager(QObject *parent = nullptr);
|
||||
~ConfigurationManager() = default;
|
||||
ConfigurationManager(const ConfigurationManager &) = delete;
|
||||
ConfigurationManager &operator=(const ConfigurationManager &) = delete;
|
||||
|
||||
Settings::GeneralSettings &m_generalSettings;
|
||||
LLMCore::ProvidersManager &m_providersManager;
|
||||
LLMCore::PromptTemplateManager &m_templateManger;
|
||||
|
||||
void setupConnections();
|
||||
};
|
||||
|
||||
} // namespace QodeAssist
|
||||
21
LICENSE
@@ -1,3 +1,24 @@
|
||||
===============================================================
|
||||
ADDITIONAL TERMS UNDER GPLv3 SECTION 7(b)
|
||||
===============================================================
|
||||
|
||||
In accordance with Section 7(b) of the GNU General Public License v3.0,
|
||||
the following additional attribution term applies to QodeAssist:
|
||||
|
||||
You must preserve all author attributions, copyright notices, and the
|
||||
project name "QodeAssist" in all copies and modified versions,
|
||||
including in source file headers, the plugin metadata
|
||||
(QodeAssist.json.in), and the About dialog or equivalent user-facing
|
||||
identification. Modified versions must be clearly marked as different
|
||||
from the original.
|
||||
|
||||
This is a reasonable attribution requirement permitted under GPLv3
|
||||
§7(b) and §7(c). It supplements the notice-preservation obligations of
|
||||
§4 and §5.
|
||||
|
||||
Copyright (C) 2024-2026 Petr Mironychev
|
||||
===============================================================
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
|
||||
@@ -1,50 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "LLMClientInterface.hpp"
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <QJsonDocument>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
#include <utils/filepath.h>
|
||||
|
||||
#include <Agent.hpp>
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentFactory.hpp>
|
||||
#include <AgentRouter.hpp>
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
#include <Session.hpp>
|
||||
#include <SessionManager.hpp>
|
||||
#include <SystemPromptBuilder.hpp>
|
||||
#include "sources/common/ContextData.hpp"
|
||||
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "CodeHandler.hpp"
|
||||
#include "context/DocumentContextReader.hpp"
|
||||
#include "context/Utils.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include <llmcore/RequestConfig.hpp>
|
||||
#include <llmcore/RulesLoader.hpp>
|
||||
#include "sources/settings/PipelinesConfig.hpp"
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
LLMClientInterface::LLMClientInterface(
|
||||
const Settings::GeneralSettings &generalSettings,
|
||||
const Settings::CodeCompletionSettings &completeSettings,
|
||||
LLMCore::IProviderRegistry &providerRegistry,
|
||||
LLMCore::IPromptProvider *promptProvider,
|
||||
AgentFactory &agentFactory,
|
||||
SessionManager &sessionManager,
|
||||
Context::IDocumentReader &documentReader,
|
||||
IRequestPerformanceLogger &performanceLogger)
|
||||
: m_generalSettings(generalSettings)
|
||||
, m_completeSettings(completeSettings)
|
||||
, m_providerRegistry(providerRegistry)
|
||||
, m_promptProvider(promptProvider)
|
||||
, m_agentFactory(agentFactory)
|
||||
, m_sessionManager(sessionManager)
|
||||
, m_documentReader(documentReader)
|
||||
, m_performanceLogger(performanceLogger)
|
||||
, m_contextManager(new Context::ContextManager(this))
|
||||
@@ -66,42 +71,56 @@ void LLMClientInterface::startImpl()
|
||||
emit started();
|
||||
}
|
||||
|
||||
void LLMClientInterface::handleFullResponse(const QString &requestId, const QString &fullText)
|
||||
void LLMClientInterface::onCompletionFinished(const QString &requestId)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
|
||||
const RequestContext &ctx = it.value();
|
||||
sendCompletionToClient(fullText, ctx.originalRequest, true);
|
||||
QString fullText;
|
||||
if (Session *session = it.value().session) {
|
||||
if (auto *history = session->history(); history && !history->isEmpty())
|
||||
fullText = history->messages().back().text();
|
||||
}
|
||||
const QJsonObject originalRequest = it.value().originalRequest;
|
||||
|
||||
m_activeRequests.erase(it);
|
||||
m_performanceLogger.endTimeMeasurement(requestId);
|
||||
sendCompletionToClient(fullText, originalRequest, true);
|
||||
finishRequest(requestId);
|
||||
}
|
||||
|
||||
void LLMClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
void LLMClientInterface::onCompletionFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
|
||||
LOG_MESSAGE(QString("Request %1 failed: %2").arg(requestId, error));
|
||||
|
||||
// Send LSP error response to client
|
||||
const RequestContext &ctx = it.value();
|
||||
|
||||
QJsonObject response;
|
||||
response["jsonrpc"] = "2.0";
|
||||
response[LanguageServerProtocol::idKey] = ctx.originalRequest["id"];
|
||||
|
||||
response[LanguageServerProtocol::idKey] = it.value().originalRequest["id"];
|
||||
|
||||
QJsonObject errorObject;
|
||||
errorObject["code"] = -32603; // Internal error code
|
||||
errorObject["message"] = error;
|
||||
response["error"] = errorObject;
|
||||
|
||||
|
||||
emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response));
|
||||
|
||||
finishRequest(requestId);
|
||||
}
|
||||
|
||||
void LLMClientInterface::finishRequest(const QString &requestId)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
if (it == m_activeRequests.end())
|
||||
return;
|
||||
|
||||
Session *session = it.value().session;
|
||||
m_activeRequests.erase(it);
|
||||
m_performanceLogger.endTimeMeasurement(requestId);
|
||||
|
||||
if (session)
|
||||
m_sessionManager.release(session);
|
||||
}
|
||||
|
||||
void LLMClientInterface::sendData(const QByteArray &data)
|
||||
@@ -122,11 +141,8 @@ void LLMClientInterface::sendData(const QByteArray &data)
|
||||
} else if (method == "textDocument/didOpen") {
|
||||
handleTextDocumentDidOpen(request);
|
||||
} else if (method == "getCompletionsCycling") {
|
||||
QString requestId = request["id"].toString();
|
||||
m_performanceLogger.startTimeMeasurement(requestId);
|
||||
handleCompletion(request);
|
||||
} else if (method == "cancelRequest") {
|
||||
qDebug() << "Cancelling request";
|
||||
} else if (method == "$/cancelRequest") {
|
||||
handleCancelRequest();
|
||||
} else if (method == "exit") {
|
||||
// TODO make exit handler
|
||||
@@ -137,26 +153,15 @@ void LLMClientInterface::sendData(const QByteArray &data)
|
||||
|
||||
void LLMClientInterface::handleCancelRequest()
|
||||
{
|
||||
QSet<LLMCore::Provider *> providers;
|
||||
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
|
||||
if (it.value().provider) {
|
||||
providers.insert(it.value().provider);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto *provider : providers) {
|
||||
disconnect(provider, nullptr, this, nullptr);
|
||||
}
|
||||
|
||||
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
|
||||
const RequestContext &ctx = it.value();
|
||||
if (ctx.provider) {
|
||||
ctx.provider->cancelRequest(it.key());
|
||||
}
|
||||
}
|
||||
|
||||
const auto requests = m_activeRequests;
|
||||
m_activeRequests.clear();
|
||||
|
||||
for (auto it = requests.begin(); it != requests.end(); ++it) {
|
||||
m_performanceLogger.endTimeMeasurement(it.key());
|
||||
if (Session *session = it.value().session)
|
||||
m_sessionManager.release(session);
|
||||
}
|
||||
|
||||
LOG_MESSAGE("All requests cancelled and state cleared");
|
||||
}
|
||||
|
||||
@@ -239,150 +244,87 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
return;
|
||||
}
|
||||
|
||||
auto updatedContext = prepareContext(request, documentInfo);
|
||||
|
||||
bool isPreset1Active = m_contextManager->isSpecifyCompletion(documentInfo);
|
||||
|
||||
const auto providerName = !isPreset1Active ? m_generalSettings.ccProvider()
|
||||
: m_generalSettings.ccPreset1Provider();
|
||||
const auto modelName = !isPreset1Active ? m_generalSettings.ccModel()
|
||||
: m_generalSettings.ccPreset1Model();
|
||||
const auto url = !isPreset1Active ? m_generalSettings.ccUrl()
|
||||
: m_generalSettings.ccPreset1Url();
|
||||
|
||||
const auto provider = m_providerRegistry.getProviderByName(providerName);
|
||||
|
||||
if (!provider) {
|
||||
QString error = QString("No provider found with name: %1").arg(providerName);
|
||||
const QString agentName = pickCompletionAgent(filePath);
|
||||
if (agentName.isEmpty()) {
|
||||
QString error = QString("No code completion agent matches: %1").arg(filePath);
|
||||
LOG_MESSAGE(error);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate()
|
||||
: m_generalSettings.ccPreset1Template();
|
||||
QString sessionError;
|
||||
Session *session = m_sessionManager.acquire(agentName, &sessionError);
|
||||
if (!session) {
|
||||
LOG_MESSAGE(sessionError);
|
||||
sendErrorResponse(request, sessionError);
|
||||
return;
|
||||
}
|
||||
|
||||
auto promptTemplate = m_promptProvider->getTemplateByName(templateName);
|
||||
Templates::ContextData context = prepareContext(request, documentInfo);
|
||||
|
||||
if (!promptTemplate) {
|
||||
QString error = QString("No template found with name: %1").arg(templateName);
|
||||
QString editorContext;
|
||||
if (context.fileContext.has_value())
|
||||
editorContext.append(context.fileContext.value());
|
||||
|
||||
if (m_completeSettings.useOpenFilesContext())
|
||||
editorContext.append(m_contextManager->openedFilesContext({filePath}));
|
||||
|
||||
if (!editorContext.isEmpty())
|
||||
session->systemPrompt()->setLayer(QStringLiteral("completion.context"), editorContext);
|
||||
|
||||
connect(session, &Session::finished, this, [this, session](const LLMQore::RequestID &, const QString &) {
|
||||
onCompletionFinished(requestIdForSession(session));
|
||||
});
|
||||
connect(session, &Session::failed, this, [this, session](const LLMQore::RequestID &, const QodeAssist::ErrorInfo &error) {
|
||||
onCompletionFailed(requestIdForSession(session), error.message);
|
||||
});
|
||||
|
||||
if (auto *client = session->client())
|
||||
client->setTransferTimeout(
|
||||
static_cast<int>(m_generalSettings.requestTimeout() * 1000));
|
||||
|
||||
std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
|
||||
blocks.push_back(std::make_unique<CompletionContent>(
|
||||
context.prefix.value_or(QString()), context.suffix.value_or(QString())));
|
||||
const LLMQore::RequestID requestId = session->send(std::move(blocks), /*toolsOverride=*/false);
|
||||
if (requestId.isEmpty()) {
|
||||
QString error = QString("Failed to start completion request for agent '%1': %2")
|
||||
.arg(agentName, session->lastError().message);
|
||||
session->deleteLater();
|
||||
LOG_MESSAGE(error);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO refactor to dynamic presets system
|
||||
LLMCore::LLMConfig config;
|
||||
config.requestType = LLMCore::RequestType::CodeCompletion;
|
||||
config.provider = provider;
|
||||
config.promptTemplate = promptTemplate;
|
||||
// TODO refactor networking
|
||||
if (provider->providerID() == LLMCore::ProviderID::GoogleAI) {
|
||||
QString stream = QString{"streamGenerateContent?alt=sse"};
|
||||
config.url = QUrl(QString("%1/models/%2:%3").arg(url, modelName, stream));
|
||||
} else {
|
||||
config.url = QUrl(
|
||||
QString("%1%2").arg(url, endpoint(provider, promptTemplate->type(), isPreset1Active)));
|
||||
config.providerRequest = {{"model", modelName}, {"stream", true}};
|
||||
}
|
||||
config.apiKey = provider->apiKey();
|
||||
config.multiLineCompletion = m_completeSettings.multiLineCompletion();
|
||||
|
||||
const auto stopWords = QJsonArray::fromStringList(config.promptTemplate->stopWords());
|
||||
if (!stopWords.isEmpty())
|
||||
config.providerRequest["stop"] = stopWords;
|
||||
|
||||
QString systemPrompt;
|
||||
if (m_completeSettings.useSystemPrompt())
|
||||
systemPrompt.append(
|
||||
m_completeSettings.useUserMessageTemplateForCC()
|
||||
&& promptTemplate->type() == LLMCore::TemplateType::Chat
|
||||
? m_completeSettings.systemPromptForNonFimModels()
|
||||
: m_completeSettings.systemPrompt());
|
||||
|
||||
auto project = LLMCore::RulesLoader::getActiveProject();
|
||||
if (project) {
|
||||
QString projectRules
|
||||
= LLMCore::RulesLoader::loadRulesForProject(project, LLMCore::RulesContext::Completions);
|
||||
|
||||
if (!projectRules.isEmpty()) {
|
||||
systemPrompt += "\n\n# Project Rules\n\n" + projectRules;
|
||||
LOG_MESSAGE("Loaded project rules for completion");
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedContext.fileContext.has_value())
|
||||
systemPrompt.append(updatedContext.fileContext.value());
|
||||
|
||||
if (m_completeSettings.useOpenFilesContext()) {
|
||||
if (provider->providerID() == LLMCore::ProviderID::LlamaCpp) {
|
||||
for (const auto openedFilePath : m_contextManager->openedFiles({filePath})) {
|
||||
if (!updatedContext.filesMetadata) {
|
||||
updatedContext.filesMetadata = QList<LLMCore::FileMetadata>();
|
||||
}
|
||||
updatedContext.filesMetadata->append({openedFilePath.first, openedFilePath.second});
|
||||
}
|
||||
} else {
|
||||
systemPrompt.append(m_contextManager->openedFilesContext({filePath}));
|
||||
}
|
||||
}
|
||||
|
||||
updatedContext.systemPrompt = systemPrompt;
|
||||
|
||||
if (promptTemplate->type() == LLMCore::TemplateType::Chat) {
|
||||
QString userMessage;
|
||||
if (m_completeSettings.useUserMessageTemplateForCC()) {
|
||||
userMessage = m_completeSettings.processMessageToFIM(
|
||||
updatedContext.prefix.value_or(""), updatedContext.suffix.value_or(""));
|
||||
} else {
|
||||
userMessage = updatedContext.prefix.value_or("") + updatedContext.suffix.value_or("");
|
||||
}
|
||||
|
||||
// TODO refactor add message
|
||||
QVector<LLMCore::Message> messages;
|
||||
messages.append({"user", userMessage});
|
||||
updatedContext.history = messages;
|
||||
}
|
||||
|
||||
config.provider->prepareRequest(
|
||||
config.providerRequest,
|
||||
promptTemplate,
|
||||
updatedContext,
|
||||
LLMCore::RequestType::CodeCompletion,
|
||||
false,
|
||||
false);
|
||||
|
||||
auto errors = config.provider->validateRequest(config.providerRequest, promptTemplate->type());
|
||||
if (!errors.isEmpty()) {
|
||||
QString error = QString("Request validation failed: %1").arg(errors.join("; "));
|
||||
LOG_MESSAGE("Validate errors for request:");
|
||||
LOG_MESSAGES(errors);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
QString requestId = request["id"].toString();
|
||||
m_activeRequests[requestId] = {request, session};
|
||||
m_performanceLogger.startTimeMeasurement(requestId);
|
||||
|
||||
m_activeRequests[requestId] = {request, provider};
|
||||
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::fullResponseReceived,
|
||||
this,
|
||||
&LLMClientInterface::handleFullResponse,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::requestFailed,
|
||||
this,
|
||||
&LLMClientInterface::handleRequestFailed,
|
||||
Qt::UniqueConnection);
|
||||
|
||||
provider->sendRequest(requestId, config.url, config.providerRequest);
|
||||
}
|
||||
|
||||
LLMCore::ContextData LLMClientInterface::prepareContext(
|
||||
QString LLMClientInterface::pickCompletionAgent(const QString &filePath) const
|
||||
{
|
||||
const QStringList roster = Settings::PipelinesConfig::load().rosters.codeCompletion;
|
||||
if (roster.isEmpty())
|
||||
return {};
|
||||
|
||||
AgentRouter::Context ctx;
|
||||
ctx.filePath = filePath;
|
||||
if (auto *project = ProjectExplorer::ProjectManager::projectForFile(
|
||||
Utils::FilePath::fromString(filePath)))
|
||||
ctx.projectName = project->displayName();
|
||||
|
||||
return AgentRouter::pickAgent(roster, ctx, m_agentFactory);
|
||||
}
|
||||
|
||||
QString LLMClientInterface::requestIdForSession(Session *session) const
|
||||
{
|
||||
for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) {
|
||||
if (it.value().session == session)
|
||||
return it.key();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Templates::ContextData LLMClientInterface::prepareContext(
|
||||
const QJsonObject &request, const Context::DocumentInfo &documentInfo)
|
||||
{
|
||||
QJsonObject params = request["params"].toObject();
|
||||
@@ -396,26 +338,6 @@ LLMCore::ContextData LLMClientInterface::prepareContext(
|
||||
return reader.prepareContext(lineNumber, cursorPosition, m_completeSettings);
|
||||
}
|
||||
|
||||
QString LLMClientInterface::endpoint(
|
||||
LLMCore::Provider *provider, LLMCore::TemplateType type, bool isLanguageSpecify)
|
||||
{
|
||||
QString endpoint;
|
||||
auto endpointMode = isLanguageSpecify ? m_generalSettings.ccPreset1EndpointMode.stringValue()
|
||||
: m_generalSettings.ccEndpointMode.stringValue();
|
||||
if (endpointMode == "Auto") {
|
||||
endpoint = type == LLMCore::TemplateType::FIM ? provider->completionEndpoint()
|
||||
: provider->chatEndpoint();
|
||||
} else if (endpointMode == "Custom") {
|
||||
endpoint = isLanguageSpecify ? m_generalSettings.ccPreset1CustomEndpoint()
|
||||
: m_generalSettings.ccCustomEndpoint();
|
||||
} else if (endpointMode == "FIM") {
|
||||
endpoint = provider->completionEndpoint();
|
||||
} else if (endpointMode == "Chat") {
|
||||
endpoint = provider->chatEndpoint();
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
Context::ContextManager *LLMClientInterface::contextManager() const
|
||||
{
|
||||
return m_contextManager;
|
||||
@@ -424,15 +346,6 @@ Context::ContextManager *LLMClientInterface::contextManager() const
|
||||
void LLMClientInterface::sendCompletionToClient(
|
||||
const QString &completion, const QJsonObject &request, bool isComplete)
|
||||
{
|
||||
auto filePath = Context::extractFilePathFromRequest(request);
|
||||
auto documentInfo = m_documentReader.readDocument(filePath);
|
||||
bool isPreset1Active = m_contextManager->isSpecifyCompletion(documentInfo);
|
||||
|
||||
auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate()
|
||||
: m_generalSettings.ccPreset1Template();
|
||||
|
||||
auto promptTemplate = m_promptProvider->getTemplateByName(templateName);
|
||||
|
||||
QJsonObject position = request["params"].toObject()["doc"].toObject()["position"].toObject();
|
||||
|
||||
QJsonObject response;
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <languageclient/languageclientinterface.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
#include <QPointer>
|
||||
|
||||
#include <context/ContextManager.hpp>
|
||||
#include <context/IDocumentReader.hpp>
|
||||
#include <context/ProgrammingLanguage.hpp>
|
||||
#include <llmcore/ContextData.hpp>
|
||||
#include <llmcore/IPromptProvider.hpp>
|
||||
#include <llmcore/IProviderRegistry.hpp>
|
||||
#include <logger/IRequestPerformanceLogger.hpp>
|
||||
#include <settings/CodeCompletionSettings.hpp>
|
||||
#include <settings/GeneralSettings.hpp>
|
||||
@@ -37,6 +22,14 @@ class QNetworkAccessManager;
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
class AgentFactory;
|
||||
class Session;
|
||||
class SessionManager;
|
||||
|
||||
namespace Templates {
|
||||
struct ContextData;
|
||||
}
|
||||
|
||||
class LLMClientInterface : public LanguageClient::BaseClientInterface
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -45,8 +38,8 @@ public:
|
||||
LLMClientInterface(
|
||||
const Settings::GeneralSettings &generalSettings,
|
||||
const Settings::CodeCompletionSettings &completeSettings,
|
||||
LLMCore::IProviderRegistry &providerRegistry,
|
||||
LLMCore::IPromptProvider *promptProvider,
|
||||
AgentFactory &agentFactory,
|
||||
SessionManager &sessionManager,
|
||||
Context::IDocumentReader &documentReader,
|
||||
IRequestPerformanceLogger &performanceLogger);
|
||||
~LLMClientInterface() override;
|
||||
@@ -66,10 +59,6 @@ public:
|
||||
protected:
|
||||
void startImpl() override;
|
||||
|
||||
private slots:
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
|
||||
private:
|
||||
void handleInitialize(const QJsonObject &request);
|
||||
void handleShutdown(const QJsonObject &request);
|
||||
@@ -79,20 +68,26 @@ private:
|
||||
void handleCancelRequest();
|
||||
void sendErrorResponse(const QJsonObject &request, const QString &errorMessage);
|
||||
|
||||
void onCompletionFinished(const QString &requestId);
|
||||
void onCompletionFailed(const QString &requestId, const QString &error);
|
||||
void finishRequest(const QString &requestId);
|
||||
QString requestIdForSession(Session *session) const;
|
||||
|
||||
struct RequestContext
|
||||
{
|
||||
QJsonObject originalRequest;
|
||||
LLMCore::Provider *provider;
|
||||
QPointer<Session> session;
|
||||
};
|
||||
|
||||
LLMCore::ContextData prepareContext(
|
||||
Templates::ContextData prepareContext(
|
||||
const QJsonObject &request, const Context::DocumentInfo &documentInfo);
|
||||
QString endpoint(LLMCore::Provider *provider, LLMCore::TemplateType type, bool isLanguageSpecify);
|
||||
|
||||
QString pickCompletionAgent(const QString &filePath) const;
|
||||
|
||||
const Settings::CodeCompletionSettings &m_completeSettings;
|
||||
const Settings::GeneralSettings &m_generalSettings;
|
||||
LLMCore::IPromptProvider *m_promptProvider = nullptr;
|
||||
LLMCore::IProviderRegistry &m_providerRegistry;
|
||||
AgentFactory &m_agentFactory;
|
||||
SessionManager &m_sessionManager;
|
||||
Context::IDocumentReader &m_documentReader;
|
||||
IRequestPerformanceLogger &m_performanceLogger;
|
||||
QElapsedTimer m_completionTimer;
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Qt Company Ltd.
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* The Qt Company portions:
|
||||
* SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0
|
||||
*
|
||||
* Petr Mironychev portions:
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "LLMSuggestion.hpp"
|
||||
#include <texteditor/texteditor.h>
|
||||
@@ -29,56 +10,43 @@
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
static QStringList extractTokens(const QString &str)
|
||||
static bool isClosingTail(const QString &s, int from)
|
||||
{
|
||||
QStringList tokens;
|
||||
QString currentToken;
|
||||
for (const QChar &ch : str) {
|
||||
if (ch.isLetterOrNumber() || ch == '_') {
|
||||
currentToken += ch;
|
||||
} else {
|
||||
if (!currentToken.isEmpty() && currentToken.length() > 1) {
|
||||
tokens.append(currentToken);
|
||||
}
|
||||
currentToken.clear();
|
||||
}
|
||||
static const QString closeChars = QStringLiteral("(){}[];,");
|
||||
for (int i = from; i < s.size(); ++i) {
|
||||
const QChar c = s.at(i);
|
||||
if (!c.isSpace() && !closeChars.contains(c))
|
||||
return false;
|
||||
}
|
||||
if (!currentToken.isEmpty() && currentToken.length() > 1) {
|
||||
tokens.append(currentToken);
|
||||
}
|
||||
return tokens;
|
||||
return true;
|
||||
}
|
||||
|
||||
int LLMSuggestion::calculateReplaceLength(const QString &suggestion,
|
||||
const QString &rightText,
|
||||
const QString &entireLine)
|
||||
int LLMSuggestion::calculateReplaceLength(const QString &suggestion, const QString &rightText)
|
||||
{
|
||||
if (rightText.isEmpty()) {
|
||||
if (rightText.isEmpty())
|
||||
return 0;
|
||||
|
||||
const int maxN = qMin(suggestion.size(), rightText.size());
|
||||
int lcp = 0;
|
||||
while (lcp < maxN && suggestion.at(lcp) == rightText.at(lcp))
|
||||
++lcp;
|
||||
|
||||
if (lcp > 0) {
|
||||
if (isClosingTail(rightText, lcp))
|
||||
return rightText.size();
|
||||
return lcp;
|
||||
}
|
||||
|
||||
QString structuralChars = "{}[]()<>;,";
|
||||
bool hasStructuralOverlap = false;
|
||||
for (const QChar &ch : structuralChars) {
|
||||
if (suggestion.contains(ch) && rightText.contains(ch)) {
|
||||
hasStructuralOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStructuralOverlap) {
|
||||
return rightText.length();
|
||||
}
|
||||
if (!isClosingTail(rightText, 0))
|
||||
return 0;
|
||||
|
||||
static const QString closeChars = QStringLiteral("(){}[];,");
|
||||
int i = suggestion.size() - 1;
|
||||
while (i >= 0 && suggestion.at(i).isSpace())
|
||||
--i;
|
||||
if (i >= 0 && closeChars.contains(suggestion.at(i)) && rightText.contains(suggestion.at(i)))
|
||||
return rightText.size();
|
||||
|
||||
const QStringList suggestionTokens = extractTokens(suggestion);
|
||||
const QStringList lineTokens = extractTokens(entireLine);
|
||||
|
||||
for (const auto &token : suggestionTokens) {
|
||||
if (lineTokens.contains(token)) {
|
||||
return rightText.length();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -102,22 +70,21 @@ LLMSuggestion::LLMSuggestion(
|
||||
QString rightText = blockText.mid(cursorPositionInBlock);
|
||||
|
||||
QString suggestionText = data.text;
|
||||
QString entireLine = blockText;
|
||||
|
||||
if (!suggestionText.contains('\n')) {
|
||||
int replaceLength = calculateReplaceLength(suggestionText, rightText, entireLine);
|
||||
int replaceLength = calculateReplaceLength(suggestionText, rightText);
|
||||
QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText;
|
||||
|
||||
|
||||
QString displayText = leftText + suggestionText + remainingRightText;
|
||||
replacementDocument()->setPlainText(displayText);
|
||||
} else {
|
||||
int firstLineEnd = suggestionText.indexOf('\n');
|
||||
QString firstLine = suggestionText.left(firstLineEnd);
|
||||
QString restOfCompletion = suggestionText.mid(firstLineEnd);
|
||||
|
||||
int replaceLength = calculateReplaceLength(firstLine, rightText, entireLine);
|
||||
|
||||
int replaceLength = calculateReplaceLength(firstLine, rightText);
|
||||
QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText;
|
||||
|
||||
|
||||
QString displayText = leftText + firstLine + remainingRightText + restOfCompletion;
|
||||
replacementDocument()->setPlainText(displayText);
|
||||
}
|
||||
@@ -167,10 +134,9 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
|
||||
if (startPos == 0) {
|
||||
QTextBlock currentBlock = cursor.block();
|
||||
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
|
||||
QString entireLine = currentBlock.text();
|
||||
|
||||
int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine);
|
||||
|
||||
|
||||
int replaceLength = calculateReplaceLength(text, textAfterCursor);
|
||||
|
||||
if (replaceLength > 0) {
|
||||
currentCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
|
||||
currentCursor.removeSelectedText();
|
||||
@@ -220,9 +186,7 @@ bool LLMSuggestion::apply()
|
||||
QString text = currentData.text;
|
||||
|
||||
QTextBlock currentBlock = cursor.block();
|
||||
QString textBeforeCursor = currentBlock.text().left(cursor.positionInBlock());
|
||||
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
|
||||
QString entireLine = currentBlock.text();
|
||||
|
||||
QTextCursor editCursor = cursor;
|
||||
editCursor.beginEditBlock();
|
||||
@@ -232,22 +196,22 @@ bool LLMSuggestion::apply()
|
||||
QString firstLine = text.left(firstLineEnd);
|
||||
QString restOfText = text.mid(firstLineEnd);
|
||||
|
||||
int replaceLength = calculateReplaceLength(firstLine, textAfterCursor, entireLine);
|
||||
|
||||
int replaceLength = calculateReplaceLength(firstLine, textAfterCursor);
|
||||
|
||||
if (replaceLength > 0) {
|
||||
editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
|
||||
editCursor.removeSelectedText();
|
||||
}
|
||||
|
||||
|
||||
editCursor.insertText(firstLine + restOfText);
|
||||
} else {
|
||||
int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine);
|
||||
|
||||
int replaceLength = calculateReplaceLength(text, textAfterCursor);
|
||||
|
||||
if (replaceLength > 0) {
|
||||
editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
|
||||
editCursor.removeSelectedText();
|
||||
}
|
||||
|
||||
|
||||
editCursor.insertText(text);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Qt Company Ltd.
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
* Copyright (C) 2024-2026 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
@@ -20,6 +20,8 @@
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
@@ -42,8 +44,6 @@ public:
|
||||
bool applyPart(Part part, TextEditor::TextEditorWidget *widget);
|
||||
bool apply() override;
|
||||
|
||||
static int calculateReplaceLength(const QString &suggestion,
|
||||
const QString &rightText,
|
||||
const QString &entireLine);
|
||||
static int calculateReplaceLength(const QString &suggestion, const QString &rightText);
|
||||
};
|
||||
} // namespace QodeAssist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Qt Company Ltd.
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
* Copyright (C) 2024-2026 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
@@ -20,6 +20,8 @@
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"Id" : "qodeassist",
|
||||
"Name" : "QodeAssist",
|
||||
"Version" : "0.9.0",
|
||||
"Version" : "0.9.20",
|
||||
"CompatVersion" : "${IDE_VERSION}",
|
||||
"Vendor" : "Petr Mironychev",
|
||||
"VendorId" : "petrmironychev",
|
||||
"Copyright" : "(C) ${IDE_COPYRIGHT_YEAR} Petr Mironychev, (C) ${IDE_COPYRIGHT_YEAR} The Qt Company Ltd",
|
||||
"License" : "GPLv3",
|
||||
"License" : "GPLv3 with additional attribution terms (§7b) — see LICENSE",
|
||||
"Description": "QodeAssist is an AI-powered coding assistant for Qt Creator. It provides intelligent code completion and suggestions for your code. Prerequisites: Requires one of the supported LLM providers installed (e.g., Ollama or LM Studio) and a compatible large language model downloaded for your chosen provider (e.g., CodeLlama, StarCoder2).",
|
||||
"Url" : "https://github.com/Palm1r/QodeAssist",
|
||||
"DocumentationUrl" : "https://github.com/Palm1r/QodeAssist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Qt Company Ltd.
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
* Copyright (C) 2024-2026 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
@@ -20,6 +20,8 @@
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
*/
|
||||
|
||||
#include "QodeAssistClient.hpp"
|
||||
@@ -40,6 +42,9 @@
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProjectSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "widgets/RefactorWidgetHandler.hpp"
|
||||
#include "RefactorContextHelper.hpp"
|
||||
#include <context/ChangesManager.h>
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
@@ -51,6 +56,90 @@ using namespace Core;
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
namespace {
|
||||
Utils::Text::Position toTextPos(const Utils::Text::Position &pos)
|
||||
{
|
||||
return Utils::Text::Position{pos.line, pos.column};
|
||||
}
|
||||
|
||||
bool isIdentifierChar(QChar c)
|
||||
{
|
||||
return c.isLetterOrNumber() || c == QLatin1Char('_');
|
||||
}
|
||||
|
||||
bool isInsideIdentifier(const QTextCursor &cursor)
|
||||
{
|
||||
const QTextBlock block = cursor.block();
|
||||
const int col = cursor.positionInBlock();
|
||||
const QString text = block.text();
|
||||
if (col <= 0 || col > text.size())
|
||||
return false;
|
||||
if (!isIdentifierChar(text.at(col - 1)))
|
||||
return false;
|
||||
return col < text.size() && isIdentifierChar(text.at(col));
|
||||
}
|
||||
|
||||
bool isAfterMemberAccess(const QTextCursor &cursor)
|
||||
{
|
||||
const QTextBlock block = cursor.block();
|
||||
const int col = cursor.positionInBlock();
|
||||
const QString text = block.text();
|
||||
if (col <= 0)
|
||||
return false;
|
||||
|
||||
int i = col - 1;
|
||||
while (i >= 0 && isIdentifierChar(text.at(i)))
|
||||
--i;
|
||||
|
||||
if (i < 0)
|
||||
return false;
|
||||
|
||||
const QChar c = text.at(i);
|
||||
if (c == QLatin1Char('.'))
|
||||
return true;
|
||||
if (c == QLatin1Char('>') && i >= 1 && text.at(i - 1) == QLatin1Char('-'))
|
||||
return true;
|
||||
if (c == QLatin1Char(':') && i >= 1 && text.at(i - 1) == QLatin1Char(':'))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isFreshIndentedLine(const QTextCursor &cursor)
|
||||
{
|
||||
const QTextBlock block = cursor.block();
|
||||
const int col = cursor.positionInBlock();
|
||||
if (col == 0)
|
||||
return false;
|
||||
const QString leftText = block.text().left(col);
|
||||
for (const QChar &ch : leftText) {
|
||||
if (!ch.isSpace())
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isAfterEagerTrigger(const QTextCursor &cursor)
|
||||
{
|
||||
const QTextBlock block = cursor.block();
|
||||
const int col = cursor.positionInBlock();
|
||||
const QString text = block.text();
|
||||
int i = col - 1;
|
||||
while (i >= 0 && text.at(i).isSpace())
|
||||
--i;
|
||||
if (i < 0)
|
||||
return false;
|
||||
const QChar c = text.at(i);
|
||||
return c == QLatin1Char('{') || c == QLatin1Char('(') || c == QLatin1Char(',')
|
||||
|| c == QLatin1Char('=') || c == QLatin1Char('[') || c == QLatin1Char(';')
|
||||
|| c == QLatin1Char(':') || c == QLatin1Char('>');
|
||||
}
|
||||
|
||||
bool isManualMode()
|
||||
{
|
||||
return Settings::codeCompletionSettings().completionMode.stringValue() == "Manual";
|
||||
}
|
||||
} // anonymous namespace
|
||||
|
||||
QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface)
|
||||
: LanguageClient::Client(clientInterface)
|
||||
, m_llmClient(clientInterface)
|
||||
@@ -67,12 +156,24 @@ QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface)
|
||||
m_typingTimer.start();
|
||||
|
||||
m_refactorHoverHandler = new RefactorSuggestionHoverHandler();
|
||||
m_refactorWidgetHandler = new RefactorWidgetHandler(this);
|
||||
}
|
||||
|
||||
void QodeAssistClient::setSessionManager(SessionManager *sessionManager)
|
||||
{
|
||||
m_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
void QodeAssistClient::setAgentFactory(AgentFactory *agentFactory)
|
||||
{
|
||||
m_agentFactory = agentFactory;
|
||||
}
|
||||
|
||||
QodeAssistClient::~QodeAssistClient()
|
||||
{
|
||||
cleanupConnections();
|
||||
delete m_refactorHoverHandler;
|
||||
delete m_refactorWidgetHandler;
|
||||
}
|
||||
|
||||
void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
@@ -90,6 +191,7 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
widget->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
connect(
|
||||
document,
|
||||
&TextDocument::contentsChangedWithPosition,
|
||||
@@ -98,6 +200,9 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
if (!Settings::codeCompletionSettings().autoCompletion())
|
||||
return;
|
||||
|
||||
if (isManualMode())
|
||||
return;
|
||||
|
||||
auto project = ProjectManager::projectForFile(document->filePath());
|
||||
if (!isEnabled(project))
|
||||
return;
|
||||
@@ -126,34 +231,33 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
|
||||
QTextCursor cursor = widget->textCursor();
|
||||
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 1);
|
||||
QString lastChar = cursor.selectedText();
|
||||
const QString lastChar = cursor.selectedText();
|
||||
if (lastChar.isEmpty())
|
||||
return;
|
||||
|
||||
if (lastChar.isEmpty() || lastChar[0].isPunct()) {
|
||||
const QChar lastCh = lastChar[0];
|
||||
if (lastCh == QLatin1Char('\n') || lastCh == QChar::ParagraphSeparator
|
||||
|| lastCh == QChar::LineSeparator) {
|
||||
m_recentCharCount = 0;
|
||||
m_typingTimer.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
m_recentCharCount += charsAdded;
|
||||
const bool isSpaceOrTab = lastCh.isSpace();
|
||||
const bool ignoreWhitespace
|
||||
= Settings::codeCompletionSettings().ignoreWhitespaceInCharCount();
|
||||
|
||||
if (!ignoreWhitespace || !isSpaceOrTab)
|
||||
m_recentCharCount += charsAdded;
|
||||
|
||||
if (m_typingTimer.elapsed()
|
||||
> Settings::codeCompletionSettings().autoCompletionTypingInterval()) {
|
||||
m_recentCharCount = charsAdded;
|
||||
m_recentCharCount = (ignoreWhitespace && isSpaceOrTab) ? 0 : charsAdded;
|
||||
m_typingTimer.restart();
|
||||
}
|
||||
|
||||
if (m_recentCharCount
|
||||
> Settings::codeCompletionSettings().autoCompletionCharThreshold()) {
|
||||
scheduleRequest(widget);
|
||||
}
|
||||
handleAutoRequestTrigger(widget);
|
||||
});
|
||||
|
||||
// auto editors = BaseTextEditor::textEditorsForDocument(document);
|
||||
// connect(
|
||||
// editors.first()->editorWidget(),
|
||||
// &TextEditorWidget::selectionChanged,
|
||||
// this,
|
||||
// [this, editors]() { m_chatButtonHandler.showButton(editors.first()->editorWidget()); });
|
||||
}
|
||||
|
||||
bool QodeAssistClient::canOpenProject(ProjectExplorer::Project *project)
|
||||
@@ -168,9 +272,9 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor)
|
||||
if (!isEnabled(project))
|
||||
return;
|
||||
|
||||
if (m_llmClient->contextManager()
|
||||
->ignoreManager()
|
||||
->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) {
|
||||
|
||||
if (m_llmClient->contextManager()->shouldIgnore(
|
||||
editor->textDocument()->filePath().toUrlishString())) {
|
||||
LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1")
|
||||
.arg(editor->textDocument()->filePath().toUrlishString()));
|
||||
return;
|
||||
@@ -180,13 +284,16 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor)
|
||||
if (cursor.hasMultipleCursors() || cursor.hasSelection() || editor->suggestionVisible())
|
||||
return;
|
||||
|
||||
const auto &settings = Settings::codeCompletionSettings();
|
||||
if (settings.abortAssistOnRequest() && !settings.respectQtcPopup())
|
||||
editor->abortAssist();
|
||||
|
||||
const FilePath filePath = editor->textDocument()->filePath();
|
||||
GetCompletionRequest request{
|
||||
{TextDocumentIdentifier(hostPathToServerUri(filePath)),
|
||||
documentVersion(filePath),
|
||||
Position(cursor.mainCursor())}};
|
||||
if (Settings::codeCompletionSettings().showProgressWidget()) {
|
||||
// Setup cancel callback before showing progress
|
||||
m_progressHandler.setCancelCallback([this, editor = QPointer<TextEditorWidget>(editor)]() {
|
||||
if (editor) {
|
||||
cancelRunningRequest(editor);
|
||||
@@ -211,9 +318,8 @@ void QodeAssistClient::requestQuickRefactor(
|
||||
if (!isEnabled(project))
|
||||
return;
|
||||
|
||||
if (m_llmClient->contextManager()
|
||||
->ignoreManager()
|
||||
->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) {
|
||||
if (m_llmClient->contextManager()->shouldIgnore(
|
||||
editor->textDocument()->filePath().toUrlishString())) {
|
||||
LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1")
|
||||
.arg(editor->textDocument()->filePath().toUrlishString()));
|
||||
return;
|
||||
@@ -221,6 +327,8 @@ void QodeAssistClient::requestQuickRefactor(
|
||||
|
||||
if (!m_refactorHandler) {
|
||||
m_refactorHandler = new QuickRefactorHandler(this);
|
||||
m_refactorHandler->setSessionManager(m_sessionManager);
|
||||
m_refactorHandler->setAgentFactory(m_agentFactory);
|
||||
connect(
|
||||
m_refactorHandler,
|
||||
&QuickRefactorHandler::refactoringCompleted,
|
||||
@@ -228,7 +336,6 @@ void QodeAssistClient::requestQuickRefactor(
|
||||
&QodeAssistClient::handleRefactoringResult);
|
||||
}
|
||||
|
||||
// Setup cancel callback before showing progress
|
||||
m_progressHandler.setCancelCallback([this, editor = QPointer<TextEditorWidget>(editor)]() {
|
||||
if (editor && m_refactorHandler) {
|
||||
m_refactorHandler->cancelRequest();
|
||||
@@ -241,27 +348,29 @@ void QodeAssistClient::requestQuickRefactor(
|
||||
|
||||
void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor)
|
||||
{
|
||||
cancelRunningRequest(editor);
|
||||
if (m_runningRequests.contains(editor)) {
|
||||
if (Settings::codeCompletionSettings().cancelOnInput())
|
||||
cancelRunningRequest(editor);
|
||||
else
|
||||
return;
|
||||
}
|
||||
|
||||
auto it = m_scheduledRequests.find(editor);
|
||||
if (it == m_scheduledRequests.end()) {
|
||||
auto timer = new QTimer(this);
|
||||
timer->setSingleShot(true);
|
||||
connect(timer, &QTimer::timeout, this, [this, editor]() {
|
||||
if (editor
|
||||
&& editor->textCursor().position()
|
||||
== m_scheduledRequests[editor]->property("cursorPosition").toInt()
|
||||
&& m_recentCharCount
|
||||
> Settings::codeCompletionSettings().autoCompletionCharThreshold())
|
||||
requestCompletions(editor);
|
||||
if (!editor || m_runningRequests.contains(editor))
|
||||
return;
|
||||
if (editor->textCursor().position()
|
||||
!= m_scheduledRequests[editor]->property("cursorPosition").toInt())
|
||||
return;
|
||||
requestCompletions(editor);
|
||||
});
|
||||
connect(editor, &TextEditorWidget::destroyed, this, [this, editor]() {
|
||||
delete m_scheduledRequests.take(editor);
|
||||
cancelRunningRequest(editor);
|
||||
});
|
||||
connect(editor, &TextEditorWidget::cursorPositionChanged, this, [this, editor] {
|
||||
cancelRunningRequest(editor);
|
||||
});
|
||||
it = m_scheduledRequests.insert(editor, timer);
|
||||
}
|
||||
|
||||
@@ -272,12 +381,14 @@ void QodeAssistClient::handleCompletions(
|
||||
const GetCompletionRequest::Response &response, TextEditor::TextEditorWidget *editor)
|
||||
{
|
||||
m_progressHandler.hideProgress();
|
||||
const auto &settings = Settings::codeCompletionSettings();
|
||||
if (settings.abortAssistOnRequest() && !settings.respectQtcPopup())
|
||||
editor->abortAssist();
|
||||
|
||||
if (response.error()) {
|
||||
log(*response.error());
|
||||
|
||||
QString errorMessage = tr("Code completion failed: %1").arg(response.error()->message());
|
||||
m_errorHandler.showError(editor, errorMessage);
|
||||
m_errorHandler
|
||||
.showError(editor, tr("Code completion failed: %1").arg(response.error()->message()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,12 +397,25 @@ void QodeAssistClient::handleCompletions(
|
||||
requestPosition = requestParams->position().toPositionInDocument(editor->document());
|
||||
|
||||
const MultiTextCursor cursors = editor->multiTextCursor();
|
||||
if (cursors.hasMultipleCursors())
|
||||
if (cursors.hasMultipleCursors() || cursors.hasSelection())
|
||||
return;
|
||||
|
||||
if (cursors.hasSelection() || cursors.mainCursor().position() != requestPosition)
|
||||
const int currentPosition = cursors.mainCursor().position();
|
||||
if (requestPosition < 0 || currentPosition < requestPosition)
|
||||
return;
|
||||
|
||||
QString typedSinceRequest;
|
||||
if (currentPosition > requestPosition) {
|
||||
QTextCursor diffCursor(editor->document());
|
||||
diffCursor.setPosition(requestPosition);
|
||||
diffCursor.setPosition(currentPosition, QTextCursor::KeepAnchor);
|
||||
typedSinceRequest = diffCursor.selectedText();
|
||||
if (typedSinceRequest.contains(QChar::ParagraphSeparator)
|
||||
|| typedSinceRequest.contains(QLatin1Char('\n'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (const std::optional<GetCompletionResponse> result = response.result()) {
|
||||
auto isValidCompletion = [](const Completion &completion) {
|
||||
return completion.isValid() && !completion.text().trimmed().isEmpty();
|
||||
@@ -299,35 +423,58 @@ void QodeAssistClient::handleCompletions(
|
||||
QList<Completion> completions
|
||||
= Utils::filtered(result->completions().toListOrEmpty(), isValidCompletion);
|
||||
|
||||
// remove trailing whitespaces from the end of the completions
|
||||
QList<Completion> matchedCompletions;
|
||||
matchedCompletions.reserve(completions.size());
|
||||
for (Completion &completion : completions) {
|
||||
const LanguageServerProtocol::Range range = completion.range();
|
||||
if (range.start().line() != range.end().line())
|
||||
continue; // do not remove trailing whitespaces for multi-line replacements
|
||||
continue;
|
||||
|
||||
const QString completionText = completion.text();
|
||||
const int end = int(completionText.size()) - 1; // empty strings have been removed above
|
||||
QString completionText = completion.text();
|
||||
const int end = int(completionText.size()) - 1;
|
||||
int delta = 0;
|
||||
while (delta <= end && completionText[end - delta].isSpace())
|
||||
++delta;
|
||||
|
||||
if (delta > 0)
|
||||
completion.setText(completionText.chopped(delta));
|
||||
completionText.chop(delta);
|
||||
|
||||
if (!typedSinceRequest.isEmpty()) {
|
||||
if (!completionText.startsWith(typedSinceRequest))
|
||||
continue;
|
||||
completionText = completionText.mid(typedSinceRequest.size());
|
||||
if (completionText.isEmpty())
|
||||
continue;
|
||||
}
|
||||
|
||||
completion.setText(completionText);
|
||||
matchedCompletions.append(completion);
|
||||
}
|
||||
auto suggestions = Utils::transform(completions, [](const Completion &c) {
|
||||
|
||||
if (matchedCompletions.isEmpty()) {
|
||||
LOG_MESSAGE("No valid completions received");
|
||||
return;
|
||||
}
|
||||
|
||||
const Text::Position anchor = typedSinceRequest.isEmpty()
|
||||
? Text::Position{}
|
||||
: Text::Position::fromPositionInDocument(editor->document(), currentPosition);
|
||||
const bool useAnchor = !typedSinceRequest.isEmpty();
|
||||
|
||||
auto suggestions = Utils::transform(matchedCompletions,
|
||||
[useAnchor, &anchor](const Completion &c) {
|
||||
auto toTextPos = [](const LanguageServerProtocol::Position pos) {
|
||||
return Text::Position{pos.line() + 1, pos.character()};
|
||||
};
|
||||
|
||||
if (useAnchor) {
|
||||
return TextSuggestion::Data{Text::Range{anchor, anchor}, anchor, c.text()};
|
||||
}
|
||||
|
||||
Text::Range range{toTextPos(c.range().start()), toTextPos(c.range().end())};
|
||||
Text::Position pos{toTextPos(c.position())};
|
||||
return TextSuggestion::Data{range, pos, c.text()};
|
||||
});
|
||||
|
||||
if (completions.isEmpty()) {
|
||||
LOG_MESSAGE("No valid completions received");
|
||||
return;
|
||||
}
|
||||
editor->insertSuggestion(std::make_unique<LLMSuggestion>(suggestions, editor->document()));
|
||||
}
|
||||
}
|
||||
@@ -384,7 +531,6 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result)
|
||||
m_progressHandler.hideProgress();
|
||||
|
||||
if (!result.success) {
|
||||
// Show error to user
|
||||
QString errorMessage = result.errorMessage.isEmpty()
|
||||
? tr("Quick refactor failed")
|
||||
: tr("Quick refactor failed: %1").arg(result.errorMessage);
|
||||
@@ -402,11 +548,18 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result)
|
||||
return;
|
||||
}
|
||||
|
||||
TextEditorWidget *editorWidget = result.editor;
|
||||
int displayMode = Settings::quickRefactorSettings().displayMode();
|
||||
|
||||
if (displayMode == 0) {
|
||||
displayRefactoringWidget(result);
|
||||
} else {
|
||||
displayRefactoringSuggestion(result);
|
||||
}
|
||||
}
|
||||
|
||||
auto toTextPos = [](const Utils::Text::Position &pos) {
|
||||
return Utils::Text::Position{pos.line, pos.column};
|
||||
};
|
||||
void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result)
|
||||
{
|
||||
TextEditorWidget *editorWidget = result.editor;
|
||||
|
||||
Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)};
|
||||
Utils::Text::Position pos = toTextPos(result.insertRange.begin);
|
||||
@@ -461,32 +614,123 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result)
|
||||
LOG_MESSAGE("Displaying refactoring suggestion with hover handler");
|
||||
}
|
||||
|
||||
void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result)
|
||||
{
|
||||
TextEditorWidget *editorWidget = result.editor;
|
||||
Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)};
|
||||
|
||||
RefactorContext ctx = RefactorContextHelper::extractContext(editorWidget, range);
|
||||
|
||||
QString displayOriginal;
|
||||
QString displayRefactored;
|
||||
QString textToApply = result.newText;
|
||||
|
||||
if (ctx.isInsertion) {
|
||||
bool isMultiline = result.newText.contains('\n');
|
||||
|
||||
if (isMultiline) {
|
||||
displayOriginal = ctx.textBeforeCursor;
|
||||
displayRefactored = ctx.textBeforeCursor + result.newText;
|
||||
} else {
|
||||
displayOriginal = ctx.textBeforeCursor + ctx.textAfterCursor;
|
||||
displayRefactored = ctx.textBeforeCursor + result.newText + ctx.textAfterCursor;
|
||||
}
|
||||
|
||||
if (!ctx.textBeforeCursor.isEmpty() || !ctx.textAfterCursor.isEmpty()) {
|
||||
textToApply = result.newText;
|
||||
}
|
||||
} else {
|
||||
displayOriginal = ctx.originalText;
|
||||
displayRefactored = result.newText;
|
||||
}
|
||||
|
||||
m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result](const QString &editedText) {
|
||||
applyRefactoringEdit(editorWidget, result.insertRange, editedText);
|
||||
});
|
||||
|
||||
m_refactorWidgetHandler->setDeclineCallback([]() {});
|
||||
|
||||
m_refactorWidgetHandler->showRefactorWidget(
|
||||
editorWidget, displayOriginal, displayRefactored, range,
|
||||
ctx.contextBefore, ctx.contextAfter);
|
||||
|
||||
m_refactorWidgetHandler->setTextToApply(textToApply);
|
||||
}
|
||||
|
||||
void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor,
|
||||
const Utils::Text::Range &range,
|
||||
const QString &text)
|
||||
{
|
||||
const QTextCursor startCursor = range.begin.toTextCursor(editor->document());
|
||||
const QTextCursor endCursor = range.end.toTextCursor(editor->document());
|
||||
const int startPos = startCursor.position();
|
||||
const int endPos = endCursor.position();
|
||||
|
||||
QTextCursor editCursor(editor->document());
|
||||
editCursor.beginEditBlock();
|
||||
|
||||
if (startPos == endPos) {
|
||||
bool isMultiline = text.contains('\n');
|
||||
editCursor.setPosition(startPos);
|
||||
|
||||
if (isMultiline) {
|
||||
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
}
|
||||
|
||||
editCursor.insertText(text);
|
||||
} else {
|
||||
editCursor.setPosition(startPos);
|
||||
editCursor.setPosition(endPos, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
editCursor.insertText(text);
|
||||
}
|
||||
|
||||
editCursor.endEditBlock();
|
||||
}
|
||||
|
||||
void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget)
|
||||
{
|
||||
const QTextCursor cursor = widget->textCursor();
|
||||
const auto &settings = Settings::codeCompletionSettings();
|
||||
const bool smart = settings.smartContextTrigger();
|
||||
|
||||
if (smart && (isInsideIdentifier(cursor) || isAfterMemberAccess(cursor)))
|
||||
return;
|
||||
|
||||
const bool eager = smart && (isFreshIndentedLine(cursor) || isAfterEagerTrigger(cursor));
|
||||
const int charThreshold = settings.autoCompletionCharThreshold();
|
||||
|
||||
if (eager || m_recentCharCount > charThreshold)
|
||||
scheduleRequest(widget);
|
||||
}
|
||||
|
||||
bool QodeAssistClient::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
|
||||
auto *editor = qobject_cast<TextEditor::TextEditorWidget *>(watched);
|
||||
if (!editor)
|
||||
return LanguageClient::Client::eventFilter(watched, event);
|
||||
|
||||
if (event->type() == QEvent::KeyPress) {
|
||||
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
||||
|
||||
if (keyEvent->key() == Qt::Key_Escape) {
|
||||
auto *editor = qobject_cast<TextEditor::TextEditorWidget *>(watched);
|
||||
|
||||
if (editor) {
|
||||
if (m_runningRequests.contains(editor)) {
|
||||
cancelRunningRequest(editor);
|
||||
}
|
||||
|
||||
if (m_scheduledRequests.contains(editor)) {
|
||||
auto *timer = m_scheduledRequests.value(editor);
|
||||
if (timer && timer->isActive()) {
|
||||
timer->stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (m_refactorHandler && m_refactorHandler->isProcessing()) {
|
||||
m_refactorHandler->cancelRequest();
|
||||
}
|
||||
|
||||
m_progressHandler.hideProgress();
|
||||
if (m_runningRequests.contains(editor)) {
|
||||
cancelRunningRequest(editor);
|
||||
}
|
||||
|
||||
if (m_scheduledRequests.contains(editor)) {
|
||||
auto *timer = m_scheduledRequests.value(editor);
|
||||
if (timer && timer->isActive()) {
|
||||
timer->stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (m_refactorHandler && m_refactorHandler->isProcessing()) {
|
||||
m_refactorHandler->cancelRequest();
|
||||
}
|
||||
|
||||
m_progressHandler.hideProgress();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Qt Company Ltd.
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* The Qt Company portions:
|
||||
* SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0
|
||||
*
|
||||
* Petr Mironychev portions:
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
#include "LLMClientInterface.hpp"
|
||||
#include "LSPCompletion.hpp"
|
||||
@@ -33,12 +15,14 @@
|
||||
#include "widgets/CompletionProgressHandler.hpp"
|
||||
#include "widgets/CompletionErrorHandler.hpp"
|
||||
#include "widgets/EditorChatButtonHandler.hpp"
|
||||
#include "widgets/RefactorWidgetHandler.hpp"
|
||||
#include <languageclient/client.h>
|
||||
#include <llmcore/IPromptProvider.hpp>
|
||||
#include <llmcore/IProviderRegistry.hpp>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
class SessionManager;
|
||||
class AgentFactory;
|
||||
|
||||
class QodeAssistClient : public LanguageClient::Client
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -46,6 +30,9 @@ public:
|
||||
explicit QodeAssistClient(LLMClientInterface *clientInterface);
|
||||
~QodeAssistClient() override;
|
||||
|
||||
void setSessionManager(SessionManager *sessionManager);
|
||||
void setAgentFactory(AgentFactory *agentFactory);
|
||||
|
||||
void openDocument(TextEditor::TextDocument *document) override;
|
||||
bool canOpenProject(ProjectExplorer::Project *project) override;
|
||||
|
||||
@@ -66,6 +53,11 @@ private:
|
||||
void setupConnections();
|
||||
void cleanupConnections();
|
||||
void handleRefactoringResult(const RefactorResult &result);
|
||||
void displayRefactoringSuggestion(const RefactorResult &result);
|
||||
void displayRefactoringWidget(const RefactorResult &result);
|
||||
void applyRefactoringEdit(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text);
|
||||
|
||||
void handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget);
|
||||
|
||||
QHash<TextEditor::TextEditorWidget *, GetCompletionRequest> m_runningRequests;
|
||||
QHash<TextEditor::TextEditorWidget *, QTimer *> m_scheduledRequests;
|
||||
@@ -79,7 +71,10 @@ private:
|
||||
EditorChatButtonHandler m_chatButtonHandler;
|
||||
QuickRefactorHandler *m_refactorHandler{nullptr};
|
||||
RefactorSuggestionHoverHandler *m_refactorHoverHandler{nullptr};
|
||||
RefactorWidgetHandler *m_refactorWidgetHandler{nullptr};
|
||||
LLMClientInterface *m_llmClient;
|
||||
SessionManager *m_sessionManager{nullptr};
|
||||
AgentFactory *m_agentFactory{nullptr};
|
||||
};
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -26,4 +11,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
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "QuickRefactorHandler.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QUuid>
|
||||
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
#include <utils/filepath.h>
|
||||
|
||||
#include <context/DocumentContextReader.hpp>
|
||||
#include <context/DocumentReaderQtCreator.hpp>
|
||||
#include <context/Utils.hpp>
|
||||
#include <llmcore/PromptTemplateManager.hpp>
|
||||
#include <llmcore/ProvidersManager.hpp>
|
||||
#include <llmcore/RequestConfig.hpp>
|
||||
#include <llmcore/RulesLoader.hpp>
|
||||
#include <logger/Logger.hpp>
|
||||
#include <settings/ChatAssistantSettings.hpp>
|
||||
#include <sources/common/ResponseCleaner.hpp>
|
||||
#include <settings/GeneralSettings.hpp>
|
||||
#include <settings/QuickRefactorSettings.hpp>
|
||||
#include <settings/ToolsSettings.hpp>
|
||||
|
||||
#include "sources/common/ContextData.hpp"
|
||||
|
||||
#include <AgentFactory.hpp>
|
||||
#include <AgentRouter.hpp>
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <Session.hpp>
|
||||
#include <SessionManager.hpp>
|
||||
#include <SystemPromptBuilder.hpp>
|
||||
|
||||
#include "sources/settings/PipelinesConfig.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
@@ -47,6 +50,16 @@ QuickRefactorHandler::QuickRefactorHandler(QObject *parent)
|
||||
|
||||
QuickRefactorHandler::~QuickRefactorHandler() {}
|
||||
|
||||
void QuickRefactorHandler::setSessionManager(SessionManager *sessionManager)
|
||||
{
|
||||
m_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::setAgentFactory(AgentFactory *agentFactory)
|
||||
{
|
||||
m_agentFactory = agentFactory;
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::sendRefactorRequest(
|
||||
TextEditor::TextEditorWidget *editor, const QString &instructions)
|
||||
{
|
||||
@@ -101,108 +114,109 @@ void QuickRefactorHandler::sendRefactorRequest(
|
||||
prepareAndSendRequest(editor, instructions, range);
|
||||
}
|
||||
|
||||
QString QuickRefactorHandler::pickRefactorAgent(const QString &filePath) const
|
||||
{
|
||||
const QStringList roster = Settings::PipelinesConfig::load().rosters.quickRefactor;
|
||||
if (roster.isEmpty() || !m_agentFactory)
|
||||
return {};
|
||||
|
||||
AgentRouter::Context ctx;
|
||||
ctx.filePath = filePath;
|
||||
if (auto *project = ProjectExplorer::ProjectManager::projectForFile(
|
||||
Utils::FilePath::fromString(filePath)))
|
||||
ctx.projectName = project->displayName();
|
||||
|
||||
return AgentRouter::pickAgent(roster, ctx, *m_agentFactory);
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::prepareAndSendRequest(
|
||||
TextEditor::TextEditorWidget *editor,
|
||||
const QString &instructions,
|
||||
const Utils::Text::Range &range)
|
||||
{
|
||||
auto &settings = Settings::generalSettings();
|
||||
|
||||
auto &providerRegistry = LLMCore::ProvidersManager::instance();
|
||||
auto &promptManager = LLMCore::PromptTemplateManager::instance();
|
||||
|
||||
const auto providerName = settings.qrProvider();
|
||||
auto provider = providerRegistry.getProviderByName(providerName);
|
||||
|
||||
if (!provider) {
|
||||
QString error = QString("No provider found with name: %1").arg(providerName);
|
||||
const auto emitError = [this, editor](const QString &error) {
|
||||
LOG_MESSAGE(error);
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = error;
|
||||
result.editor = editor;
|
||||
emit refactoringCompleted(result);
|
||||
};
|
||||
|
||||
if (!m_sessionManager) {
|
||||
emitError(QStringLiteral("Quick refactor session manager is not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto templateName = settings.qrTemplate();
|
||||
auto promptTemplate = promptManager.getChatTemplateByName(templateName);
|
||||
|
||||
if (!promptTemplate) {
|
||||
QString error = QString("No template found with name: %1").arg(templateName);
|
||||
LOG_MESSAGE(error);
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = error;
|
||||
result.editor = editor;
|
||||
emit refactoringCompleted(result);
|
||||
const QString filePath = editor->textDocument()->filePath().toUrlishString();
|
||||
const QString agentName = pickRefactorAgent(filePath);
|
||||
if (agentName.isEmpty()) {
|
||||
emitError(QStringLiteral("No quick refactor agent matches: %1").arg(filePath));
|
||||
return;
|
||||
}
|
||||
|
||||
LLMCore::LLMConfig config;
|
||||
config.requestType = LLMCore::RequestType::QuickRefactoring;
|
||||
config.provider = provider;
|
||||
config.promptTemplate = promptTemplate;
|
||||
config.url = QString("%1%2").arg(settings.qrUrl(), provider->chatEndpoint());
|
||||
config.apiKey = provider->apiKey();
|
||||
|
||||
if (provider->providerID() == LLMCore::ProviderID::GoogleAI) {
|
||||
QString stream = QString{"streamGenerateContent?alt=sse"};
|
||||
config.url = QUrl(QString("%1/models/%2:%3")
|
||||
.arg(
|
||||
Settings::generalSettings().qrUrl(),
|
||||
Settings::generalSettings().qrModel(),
|
||||
stream));
|
||||
} else {
|
||||
config.url
|
||||
= QString("%1%2").arg(Settings::generalSettings().qrUrl(), provider->chatEndpoint());
|
||||
config.providerRequest
|
||||
= {{"model", Settings::generalSettings().qrModel()}, {"stream", true}};
|
||||
QString sessionError;
|
||||
Session *session = m_sessionManager->acquire(agentName, &sessionError);
|
||||
if (!session) {
|
||||
emitError(sessionError.isEmpty() ? QStringLiteral("No quick refactor agent selected")
|
||||
: sessionError);
|
||||
return;
|
||||
}
|
||||
|
||||
LLMCore::ContextData context = prepareContext(editor, range, instructions);
|
||||
auto *client = session->client();
|
||||
if (!client) {
|
||||
m_sessionManager->removeSession(session);
|
||||
emitError(QStringLiteral("Quick refactor agent has no live client"));
|
||||
return;
|
||||
}
|
||||
|
||||
bool enableTools = Settings::quickRefactorSettings().useTools();
|
||||
bool enableThinking = Settings::quickRefactorSettings().useThinking();
|
||||
provider->prepareRequest(
|
||||
config.providerRequest,
|
||||
promptTemplate,
|
||||
context,
|
||||
LLMCore::RequestType::QuickRefactoring,
|
||||
enableTools,
|
||||
enableThinking);
|
||||
const bool enableTools = Settings::quickRefactorSettings().useTools();
|
||||
if (enableTools) {
|
||||
m_sessionManager->toolContributors().contribute(client->tools());
|
||||
client->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
|
||||
}
|
||||
|
||||
QString requestId = QUuid::createUuid().toString();
|
||||
m_lastRequestId = requestId;
|
||||
QJsonObject request{{"id", requestId}};
|
||||
session->systemPrompt()->setLayer(
|
||||
QStringLiteral("refactor"), buildSystemPrompt(editor, range));
|
||||
|
||||
client->setTransferTimeout(
|
||||
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
|
||||
|
||||
m_isRefactoringInProgress = true;
|
||||
|
||||
m_activeRequests[requestId] = {request, provider};
|
||||
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::fullResponseReceived,
|
||||
this,
|
||||
&QuickRefactorHandler::handleFullResponse,
|
||||
Qt::UniqueConnection);
|
||||
|
||||
session, &Session::finished, this,
|
||||
[this](const LLMQore::RequestID &id, const QString &) { onRefactorFinished(id); });
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::requestFailed,
|
||||
this,
|
||||
&QuickRefactorHandler::handleRequestFailed,
|
||||
Qt::UniqueConnection);
|
||||
session, &Session::failed, this,
|
||||
[this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) {
|
||||
onRefactorFailed(id, error);
|
||||
});
|
||||
|
||||
provider->sendRequest(requestId, config.url, config.providerRequest);
|
||||
std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
|
||||
const QString userMessage = instructions.isEmpty()
|
||||
? QStringLiteral("Refactor the code to improve its quality and maintainability.")
|
||||
: instructions;
|
||||
blocks.push_back(std::make_unique<LLMQore::TextContent>(userMessage));
|
||||
|
||||
const LLMQore::RequestID requestId = session->send(std::move(blocks), enableTools);
|
||||
if (requestId.isEmpty()) {
|
||||
m_isRefactoringInProgress = false;
|
||||
const QString reason = session->lastError().message;
|
||||
m_sessionManager->removeSession(session);
|
||||
emitError(QStringLiteral("Failed to start quick refactor request for agent '%1': %2")
|
||||
.arg(agentName, reason));
|
||||
return;
|
||||
}
|
||||
|
||||
m_lastRequestId = requestId;
|
||||
m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session};
|
||||
}
|
||||
|
||||
LLMCore::ContextData QuickRefactorHandler::prepareContext(
|
||||
TextEditor::TextEditorWidget *editor,
|
||||
const Utils::Text::Range &range,
|
||||
const QString &instructions)
|
||||
QString QuickRefactorHandler::buildSystemPrompt(
|
||||
TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range)
|
||||
{
|
||||
LLMCore::ContextData context;
|
||||
Q_UNUSED(range)
|
||||
|
||||
auto textDocument = editor->textDocument();
|
||||
Context::DocumentReaderQtCreator documentReader;
|
||||
@@ -210,7 +224,7 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
|
||||
|
||||
if (!documentInfo.document) {
|
||||
LOG_MESSAGE("Error: Document is not available");
|
||||
return context;
|
||||
return Settings::quickRefactorSettings().systemPrompt();
|
||||
}
|
||||
|
||||
QTextCursor cursor = editor->textCursor();
|
||||
@@ -286,130 +300,160 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
|
||||
|
||||
QString systemPrompt = Settings::quickRefactorSettings().systemPrompt();
|
||||
|
||||
auto project = LLMCore::RulesLoader::getActiveProject();
|
||||
if (project) {
|
||||
QString projectRules = LLMCore::RulesLoader::loadRulesForProject(
|
||||
project, LLMCore::RulesContext::QuickRefactor);
|
||||
|
||||
if (!projectRules.isEmpty()) {
|
||||
systemPrompt += "\n\n# Project Rules\n\n" + projectRules;
|
||||
LOG_MESSAGE("Loaded project rules for quick refactor");
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt += "\n\nFile information:";
|
||||
systemPrompt += "\nLanguage: " + documentInfo.mimeType;
|
||||
systemPrompt += "\nFile path: " + documentInfo.filePath;
|
||||
|
||||
systemPrompt += "\n\nCode context with position markers:";
|
||||
systemPrompt += taggedContent;
|
||||
systemPrompt += "\n\n# Code Context with Position Markers\n" + taggedContent;
|
||||
|
||||
systemPrompt += "\n\nOutput format:";
|
||||
systemPrompt += "\n- Generate ONLY the code that should replace the current selection "
|
||||
"between<selection_start><selection_end> or be "
|
||||
"inserted at cursor position<cursor>";
|
||||
systemPrompt += "\n- Do not include any explanations, comments about the code, or markdown "
|
||||
"code block markers";
|
||||
systemPrompt += "\n- The output should be ready to insert directly into the editor";
|
||||
systemPrompt += "\n- Follow the existing code style and indentation patterns";
|
||||
systemPrompt += "\n\n# Output Requirements\n## What to Generate:";
|
||||
systemPrompt += cursor.hasSelection()
|
||||
? "\n- Generate ONLY the code that should REPLACE the selected text between "
|
||||
"<selection_start> and <selection_end> markers"
|
||||
"\n- Your output will completely replace the selected code"
|
||||
: "\n- Generate ONLY the code that should be INSERTED at the <cursor> position"
|
||||
"\n- Your output will be inserted at the cursor location";
|
||||
|
||||
if (Settings::codeCompletionSettings().useOpenFilesInQuickRefactor()) {
|
||||
systemPrompt += "\n\n## Formatting Rules:"
|
||||
"\n- Output ONLY the code itself, without ANY explanations or descriptions"
|
||||
"\n- Do NOT include markdown code blocks (no ```, no language tags)"
|
||||
"\n- Do NOT add comments explaining what you changed"
|
||||
"\n- Do NOT repeat existing code, be precise with context"
|
||||
"\n- Do NOT send in answer <cursor> or </cursor> and other tags"
|
||||
"\n- The output must be ready to insert directly into the editor as-is";
|
||||
|
||||
systemPrompt += "\n\n## Indentation and Whitespace:";
|
||||
|
||||
if (cursor.hasSelection()) {
|
||||
QTextBlock startBlock = documentInfo.document->findBlock(cursor.selectionStart());
|
||||
int leadingSpaces = 0;
|
||||
for (QChar c : startBlock.text()) {
|
||||
if (c == ' ') leadingSpaces++;
|
||||
else if (c == '\t') leadingSpaces += 4;
|
||||
else break;
|
||||
}
|
||||
if (leadingSpaces > 0) {
|
||||
systemPrompt += QString("\n- CRITICAL: The code to replace starts with %1 spaces of indentation"
|
||||
"\n- Your output MUST start with exactly %1 spaces (or equivalent tabs)"
|
||||
"\n- Each line in your output must maintain this base indentation")
|
||||
.arg(leadingSpaces);
|
||||
}
|
||||
systemPrompt += "\n- PRESERVE all indentation from the original code";
|
||||
} else {
|
||||
QTextBlock block = documentInfo.document->findBlock(cursorPos);
|
||||
QString lineText = block.text();
|
||||
int leadingSpaces = 0;
|
||||
for (QChar c : lineText) {
|
||||
if (c == ' ') leadingSpaces++;
|
||||
else if (c == '\t') leadingSpaces += 4;
|
||||
else break;
|
||||
}
|
||||
if (leadingSpaces > 0) {
|
||||
systemPrompt += QString("\n- CRITICAL: Current line has %1 spaces of indentation"
|
||||
"\n- If generating multiline code, EVERY line must start with at least %1 spaces"
|
||||
"\n- If generating single-line code, it will be inserted inline (no indentation needed)")
|
||||
.arg(leadingSpaces);
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt += "\n- Use the same indentation style (spaces or tabs) as the surrounding code"
|
||||
"\n- Maintain consistent indentation for nested blocks"
|
||||
"\n- Do NOT remove or reduce the base indentation level"
|
||||
"\n\n## Code Style:"
|
||||
"\n- Match the coding style of the surrounding code (naming, spacing, braces, etc.)"
|
||||
"\n- Preserve the original code structure when possible"
|
||||
"\n- Only change what is necessary to fulfill the user's request";
|
||||
|
||||
if (Settings::quickRefactorSettings().useOpenFilesInQuickRefactor()) {
|
||||
systemPrompt += "\n\n" + m_contextManager.openedFilesContext({documentInfo.filePath});
|
||||
}
|
||||
|
||||
context.systemPrompt = systemPrompt;
|
||||
|
||||
QVector<LLMCore::Message> messages;
|
||||
messages.append(
|
||||
{"user",
|
||||
instructions.isEmpty() ? "Refactor the code to improve its quality and maintainability."
|
||||
: instructions});
|
||||
context.history = messages;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::handleLLMResponse(
|
||||
const QString &response, const QJsonObject &request, bool isComplete)
|
||||
{
|
||||
if (request["id"].toString() != m_lastRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
m_isRefactoringInProgress = false;
|
||||
|
||||
QString cleanedResponse = response.trimmed();
|
||||
if (cleanedResponse.startsWith("```")) {
|
||||
int firstNewLine = cleanedResponse.indexOf('\n');
|
||||
int lastFence = cleanedResponse.lastIndexOf("```");
|
||||
|
||||
if (firstNewLine != -1 && lastFence > firstNewLine) {
|
||||
cleanedResponse
|
||||
= cleanedResponse.mid(firstNewLine + 1, lastFence - firstNewLine - 1).trimmed();
|
||||
} else if (lastFence != -1) {
|
||||
cleanedResponse = cleanedResponse.mid(3, lastFence - 3).trimmed();
|
||||
}
|
||||
}
|
||||
|
||||
RefactorResult result;
|
||||
result.newText = cleanedResponse;
|
||||
result.insertRange = m_currentRange;
|
||||
result.success = true;
|
||||
result.editor = m_currentEditor;
|
||||
|
||||
LOG_MESSAGE("Refactoring completed successfully. New code to insert: ");
|
||||
LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------");
|
||||
LOG_MESSAGE(cleanedResponse);
|
||||
LOG_MESSAGE("----------- END REFACTORED CODE -----------");
|
||||
|
||||
emit refactoringCompleted(result);
|
||||
}
|
||||
return systemPrompt;
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::cancelRequest()
|
||||
{
|
||||
if (m_isRefactoringInProgress) {
|
||||
auto id = m_lastRequestId;
|
||||
if (!m_isRefactoringInProgress)
|
||||
return;
|
||||
|
||||
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
|
||||
if (it.key() == id) {
|
||||
const RequestContext &ctx = it.value();
|
||||
ctx.provider->cancelRequest(id);
|
||||
m_activeRequests.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const auto id = m_lastRequestId;
|
||||
m_isRefactoringInProgress = false;
|
||||
m_lastRequestId.clear();
|
||||
|
||||
m_isRefactoringInProgress = false;
|
||||
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = "Refactoring request was cancelled";
|
||||
emit refactoringCompleted(result);
|
||||
auto it = m_activeRequests.find(id);
|
||||
if (it != m_activeRequests.end()) {
|
||||
Session *session = it.value().session;
|
||||
m_activeRequests.erase(it);
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->release(session);
|
||||
}
|
||||
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = "Refactoring request was cancelled";
|
||||
emit refactoringCompleted(result);
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QString &fullText)
|
||||
void QuickRefactorHandler::onRefactorFinished(const QString &requestId)
|
||||
{
|
||||
if (requestId == m_lastRequestId) {
|
||||
m_activeRequests.remove(requestId);
|
||||
QJsonObject request{{"id", requestId}};
|
||||
handleLLMResponse(fullText, request, true);
|
||||
if (requestId != m_lastRequestId)
|
||||
return;
|
||||
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
Session *session = (it != m_activeRequests.end()) ? it.value().session.data() : nullptr;
|
||||
if (it != m_activeRequests.end())
|
||||
m_activeRequests.erase(it);
|
||||
|
||||
QString fullText;
|
||||
if (session) {
|
||||
if (auto *history = session->history(); history && !history->isEmpty())
|
||||
fullText = history->messages().back().text();
|
||||
}
|
||||
|
||||
m_isRefactoringInProgress = false;
|
||||
m_lastRequestId.clear();
|
||||
|
||||
const QString cleanedResponse = ResponseCleaner::clean(fullText);
|
||||
|
||||
RefactorResult result;
|
||||
result.newText = cleanedResponse;
|
||||
result.insertRange = m_currentRange;
|
||||
result.success = true;
|
||||
result.editor = m_currentEditor;
|
||||
|
||||
LOG_MESSAGE("Refactoring completed successfully. New code to insert: ");
|
||||
LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------");
|
||||
LOG_MESSAGE(cleanedResponse);
|
||||
LOG_MESSAGE("----------- END REFACTORED CODE -----------");
|
||||
|
||||
emit refactoringCompleted(result);
|
||||
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->release(session);
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
void QuickRefactorHandler::onRefactorFailed(
|
||||
const QString &requestId, const QodeAssist::ErrorInfo &error)
|
||||
{
|
||||
if (requestId == m_lastRequestId) {
|
||||
m_activeRequests.remove(requestId);
|
||||
m_isRefactoringInProgress = false;
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = error;
|
||||
result.editor = m_currentEditor;
|
||||
emit refactoringCompleted(result);
|
||||
}
|
||||
if (requestId != m_lastRequestId)
|
||||
return;
|
||||
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
Session *session = (it != m_activeRequests.end()) ? it.value().session.data() : nullptr;
|
||||
if (it != m_activeRequests.end())
|
||||
m_activeRequests.erase(it);
|
||||
|
||||
m_isRefactoringInProgress = false;
|
||||
m_lastRequestId.clear();
|
||||
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = error.message;
|
||||
result.editor = m_currentEditor;
|
||||
emit refactoringCompleted(result);
|
||||
|
||||
if (session && m_sessionManager)
|
||||
m_sessionManager->release(session);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <utils/textutils.h>
|
||||
|
||||
#include <ErrorInfo.hpp>
|
||||
#include <context/ContextManager.hpp>
|
||||
#include <context/IDocumentReader.hpp>
|
||||
#include <llmcore/ContextData.hpp>
|
||||
#include <llmcore/Provider.hpp>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
class SessionManager;
|
||||
class Session;
|
||||
class AgentFactory;
|
||||
|
||||
struct RefactorResult
|
||||
{
|
||||
QString newText;
|
||||
@@ -49,6 +39,9 @@ public:
|
||||
explicit QuickRefactorHandler(QObject *parent = nullptr);
|
||||
~QuickRefactorHandler() override;
|
||||
|
||||
void setSessionManager(SessionManager *sessionManager);
|
||||
void setAgentFactory(AgentFactory *agentFactory);
|
||||
|
||||
void sendRefactorRequest(TextEditor::TextEditorWidget *editor, const QString &instructions);
|
||||
|
||||
void cancelRequest();
|
||||
@@ -57,28 +50,26 @@ public:
|
||||
signals:
|
||||
void refactoringCompleted(const QodeAssist::RefactorResult &result);
|
||||
|
||||
private slots:
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
|
||||
private:
|
||||
void prepareAndSendRequest(
|
||||
TextEditor::TextEditorWidget *editor,
|
||||
const QString &instructions,
|
||||
const Utils::Text::Range &range);
|
||||
|
||||
void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete);
|
||||
LLMCore::ContextData prepareContext(
|
||||
TextEditor::TextEditorWidget *editor,
|
||||
const Utils::Text::Range &range,
|
||||
const QString &instructions);
|
||||
void onRefactorFinished(const QString &requestId);
|
||||
void onRefactorFailed(const QString &requestId, const QodeAssist::ErrorInfo &error);
|
||||
QString buildSystemPrompt(
|
||||
TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range);
|
||||
QString pickRefactorAgent(const QString &filePath) const;
|
||||
|
||||
struct RequestContext
|
||||
{
|
||||
QJsonObject originalRequest;
|
||||
LLMCore::Provider *provider;
|
||||
QPointer<Session> session;
|
||||
};
|
||||
|
||||
QPointer<SessionManager> m_sessionManager;
|
||||
QPointer<AgentFactory> m_agentFactory;
|
||||
QHash<QString, RequestContext> m_activeRequests;
|
||||
TextEditor::TextEditorWidget *m_currentEditor;
|
||||
Utils::Text::Range m_currentRange;
|
||||
|
||||
310
README.md
@@ -1,10 +1,12 @@
|
||||
# QodeAssist - AI-powered coding assistant plugin for Qt Creator
|
||||
[](https://github.com/Palm1r/QodeAssist/actions/workflows/build_cmake.yml)
|
||||

|
||||

|
||||
[](https://discord.gg/BGMkUsXUgf)
|
||||
# QodeAssist — AI coding assistant for Qt Creator
|
||||
|
||||
 QodeAssist is a comprehensive AI-powered coding assistant plugin for Qt Creator. It provides intelligent code completion, interactive chat with multiple interface options, inline quick refactoring, and AI function calling capabilities for C++ and QML development. Supporting both local providers (Ollama, llama.cpp, LM Studio) and cloud services (Claude, OpenAI, Google AI, Mistral AI), QodeAssist enhances your productivity with context-aware AI assistance, project-specific rules, and extensive customization options directly in your Qt development environment.
|
||||
[](https://github.com/Palm1r/QodeAssist/actions/workflows/build_cmake.yml)
|
||||
[](https://github.com/Palm1r/QodeAssist/releases)
|
||||

|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://discord.gg/BGMkUsXUgf)
|
||||
|
||||
 **QodeAssist** brings a full AI coding workflow to Qt Creator for C++ and QML — smart code completion, multi-panel chat, inline quick refactoring, and project-aware tool calling. It works with local runtimes (Ollama, llama.cpp, LM Studio) and cloud providers (Claude, OpenAI, Google AI, Mistral, Qwen, DeepSeek), can run as an **MCP server** so other clients reuse its project context, and can also act as an **MCP client** to consume tools from external MCP servers (authenticated MCP servers are not supported yet).
|
||||
|
||||
⚠️ **Important Notice About Paid Providers**
|
||||
> When using paid providers like Claude, OpenRouter or OpenAI-compatible services:
|
||||
@@ -29,13 +31,17 @@
|
||||
|
||||
QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
|
||||
- **Code Completion**: Intelligent, context-aware code suggestions for C++ and QML
|
||||
- **Chat Assistant**: Multiple interface options (popup window, side panel, bottom panel)
|
||||
- **Quick Refactoring**: Inline AI-assisted code improvements directly in editor with custom instructions library
|
||||
- **File Context**: Attach or link files for better AI understanding
|
||||
- **Tool Calling**: AI can read project files, search code, and access diagnostics
|
||||
- **Multiple Providers**: Support for Ollama, Claude, OpenAI, Google AI, Mistral AI, llama.cpp, and more
|
||||
- **Customizable**: Project-specific rules, custom instructions, and extensive model templates
|
||||
- **Code Completion** — intelligent, context-aware suggestions (FIM and chat models) for C++ and QML, with multiline support
|
||||
- **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
|
||||
- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint
|
||||
- **Reasoning / Thinking** — streamed chain-of-thought is shown for reasoning models across Claude, Google, OpenAI Responses, and any OpenAI-compatible endpoint that returns `reasoning_content` (DeepSeek, Qwen QwQ/Qwen3-Thinking, LM Studio, OpenRouter, …)
|
||||
- **Customizable** — per-project rules (`.qodeassist/rules/`), agent roles, reusable refactor templates, full prompt-template control
|
||||
|
||||
**Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users!
|
||||
|
||||
@@ -49,6 +55,11 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
<img src="https://github.com/user-attachments/assets/4a9092e0-429f-41eb-8723-cbb202fd0a8c" width="600" alt="QodeAssistPreview">
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Chat View Mode: (click to expand)</summary>
|
||||
<img src="https://github.com/user-attachments/assets/5914dd78-c8a4-4d35-889a-10ec493d4c4b" width="600" alt="QodeAssistChat2">
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Multiline Code completion: (click to expand)</summary>
|
||||
<img src="https://github.com/user-attachments/assets/c18dfbd2-8c54-4a7b-90d1-66e3bb51adb0" width="600" alt="QodeAssistPreview">
|
||||
@@ -81,7 +92,27 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
|
||||
## Install plugin to QtCreator
|
||||
|
||||
### Method 1: Using QodeAssistUpdater (Beta)
|
||||
### Method 1: Using the Extension Registry (Recommended)
|
||||
|
||||
You can install and update QodeAssist directly from within Qt Creator by adding the QodeAssist registry as an external extension repository.
|
||||
|
||||
1. Open the Extensions page (`Qt Creator → Extensions`) and switch to the **Browser** tab
|
||||
2. Enable **Use External Repository**
|
||||
3. Next to **Repository URLs**, click **Add** and paste the registry archive URL matching your Qt Creator version:
|
||||
- **Latest (QtC 19)**: `https://github.com/Palm1r/extension-registry/archive/refs/heads/qodeassist.tar.gz`
|
||||
- **QtC 19**: `https://github.com/Palm1r/extension-registry/archive/refs/heads/qodeassist-qtc19.tar.gz`
|
||||
- **QtC 18**: `https://github.com/Palm1r/extension-registry/archive/refs/heads/qodeassist-qtc18.tar.gz`
|
||||
<details>
|
||||
<summary>Example of extension registry: (click to expand)</summary>
|
||||
<img width="600" alt="RegistryExample" src="https://github.com/user-attachments/assets/8ab8cf10-72e7-4961-8c5a-21d530378a05">
|
||||
</details>
|
||||
|
||||
4. Click **Apply** — QodeAssist will appear in the extensions list, where you can **Install** it
|
||||
5. Updates can be installed from the same screen when a new version is published
|
||||
|
||||
> **Note:** This is an external repository not maintained by The Qt Company. By adding it you accept responsibility for managing the associated risks, as stated in the Extensions page.
|
||||
|
||||
### Method 2: Using QodeAssistUpdater (Beta)
|
||||
|
||||
QodeAssistUpdater is a command-line utility that automates plugin installation and updates with automatic Qt Creator version detection and checksum verification.
|
||||
|
||||
@@ -109,7 +140,7 @@ Download pre-built binary from [QodeAssistUpdater releases](https://github.com/P
|
||||
|
||||
For more information, visit the [QodeAssistUpdater repository](https://github.com/Palm1r/QodeAssistUpdater).
|
||||
|
||||
### Method 2: Manual Installation
|
||||
### Method 3: Manual Installation
|
||||
|
||||
1. Install Latest Qt Creator
|
||||
2. Download the QodeAssist plugin for your Qt Creator
|
||||
@@ -125,22 +156,69 @@ For more information, visit the [QodeAssistUpdater repository](https://github.co
|
||||
|
||||
## Configuration
|
||||
|
||||
QodeAssist supports multiple LLM providers. Choose your preferred provider and follow the configuration guide:
|
||||
### Quick Setup (Recommended for Beginners)
|
||||
|
||||
### Supported Providers
|
||||
The Quick Setup feature provides one-click configuration for popular cloud AI models. Get started in 3 easy steps:
|
||||
<details>
|
||||
<summary>Quick setup: (click to expand)</summary>
|
||||
<img width="600" alt="Quick Setup" src="https://github.com/user-attachments/assets/20df9155-9095-420c-8387-908bd931bcfa">
|
||||
</details>
|
||||
|
||||
- **[Ollama](docs/ollama-configuration.md)** - Local LLM provider
|
||||
- **[llama.cpp](docs/llamacpp-configuration.md)** - Local LLM server
|
||||
- **[Anthropic Claude](docs/claude-configuration.md)** - Сloud provider
|
||||
- **[OpenAI](docs/openai-configuration.md)** - Сloud provider
|
||||
- **[Mistral AI](docs/mistral-configuration.md)** - Сloud provider
|
||||
- **[Google AI](docs/google-ai-configuration.md)** - Сloud provider
|
||||
- **LM Studio** - Local LLM provider
|
||||
- **OpenAI-compatible** - Custom providers (OpenRouter, etc.)
|
||||
1. **Open QodeAssist Settings**
|
||||
2. **Select a Preset** - Choose from the Quick Setup dropdown:
|
||||
- **Anthropic Claude** (Sonnet 4.5, Haiku 4.5, Opus 4.5)
|
||||
- **OpenAI** (gpt-5.2-codex)
|
||||
- **Mistral AI** (Codestral 2501)
|
||||
- **Google AI** (Gemini 2.5 Flash)
|
||||
- **Qwen** (Qwen3.6 Plus, Qwen3.7 Max)
|
||||
- **DeepSeek** (DeepSeek V4 Flash, DeepSeek V4 Pro)
|
||||
3. **Configure API Key** - Click "Configure API Key" button and enter your API key in Provider Settings
|
||||
|
||||
All settings (provider, model, template, URL) are configured automatically. Just add your API key and you're ready to go!
|
||||
|
||||
### Manual Provider Configuration
|
||||
|
||||
For advanced users or local models, choose your preferred provider and follow the detailed configuration guide:
|
||||
|
||||
**Local providers:**
|
||||
- **[Ollama](docs/ollama-configuration.md)** — native Ollama API
|
||||
- **Ollama (OpenAI-compatible)** — Ollama's `/v1` endpoint for tool-calling models
|
||||
- **[llama.cpp](docs/llamacpp-configuration.md)** — local `llama-server`
|
||||
- **LM Studio** — OpenAI-compatible Chat API
|
||||
- **LM Studio (Responses API)** — newer models that require the Responses endpoint
|
||||
|
||||
**Cloud providers:**
|
||||
- **[Anthropic Claude](docs/claude-configuration.md)** — manual setup guide
|
||||
- **[OpenAI](docs/openai-configuration.md)** — Chat Completions and Responses API
|
||||
- **[Mistral AI](docs/mistral-configuration.md)** / **Codestral**
|
||||
- **[Google AI](docs/google-ai-configuration.md)** — Gemini
|
||||
- **Qwen (Alibaba)** — DashScope OpenAI-compatible Chat and Responses endpoints
|
||||
- **DeepSeek** — `deepseek-chat` and `deepseek-reasoner` (reasoning shown as thinking)
|
||||
- **OpenAI-compatible** — OpenRouter and any custom endpoint
|
||||
|
||||
### Recommended Models for Best Experience
|
||||
|
||||
For optimal coding assistance, we recommend using these top-tier models:
|
||||
|
||||
**Best for Code Completion & Refactoring:**
|
||||
- **Claude 4.5 Haiku or Sonnet** (Anthropic)
|
||||
- **GPT-5.1-codex or codex-mini** (OpenAI Responses API)
|
||||
- **Codestral** (Mistral)
|
||||
|
||||
**Best for Chat Assistant:**
|
||||
- **Claude 4.5 Sonnet** (Anthropic) - Outstanding reasoning and natural conversation flow
|
||||
- **GPT-5.1-codex** (OpenAI Responses API) - Latest model with advanced capabilities
|
||||
- **Gemini 2.5 or 3.0** (Google AI) - Latest models from Google
|
||||
- **Mistral large** (Mistral) - Fast and capable
|
||||
|
||||
**Local models:**
|
||||
- **Qwen3-coder** (Qwen) - Best local models
|
||||
|
||||
### Additional Configuration
|
||||
|
||||
- **[Project Rules](docs/project-rules.md)** - Customize AI behavior for your project
|
||||
- **[Creating and Extending Agents](docs/creating-agents.md)** - Add custom agents or override bundled ones with TOML profiles
|
||||
- **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts
|
||||
- **[Chat Summarization](docs/chat-summarization.md)** - Compress conversations to save context tokens
|
||||
- **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore`
|
||||
|
||||
## Features
|
||||
@@ -151,28 +229,111 @@ QodeAssist supports multiple LLM providers. Choose your preferred provider and f
|
||||
- Context-aware suggestions
|
||||
- Multiline completions
|
||||
|
||||
#### Completion Trigger Modes
|
||||
|
||||
QodeAssist offers two trigger modes for code completion:
|
||||
|
||||
**Hint-based**
|
||||
- Shows a hint indicator near cursor when you type 3+ characters
|
||||
- Press **Space** (or custom key) to request completion
|
||||
- **Best for**: Paid APIs (Claude, OpenAI), conscious control
|
||||
- **Benefits**: No unexpected API charges, full control over requests, no workflow interruption
|
||||
- **Visual**: Clear indicator shows when completion is ready
|
||||
|
||||
**Automatic(Default)**
|
||||
- Automatically requests completion after typing threshold
|
||||
- Works immediately without additional keypresses
|
||||
- **Best for**: Local models (Ollama, llama.cpp), maximum automation
|
||||
- **Benefits**: Hands-free experience, instant suggestions
|
||||
|
||||
💡 **Tip**: Start with Hint-based to avoid unexpected costs. Switch to Automatic if using free local models.
|
||||
|
||||
Configure in: `Tools → Options → QodeAssist → Code Completion → General Settings`
|
||||
|
||||
### Chat Assistant
|
||||
- Multiple chat panels: side panel, bottom panel, and popup window
|
||||
- Chat history with auto-save and restore
|
||||
- Token usage monitoring
|
||||
- **[Agent Roles](docs/agent-roles.md)** - Switch between AI personas (Developer, Reviewer, custom roles)
|
||||
- **[Chat Summarization](docs/chat-summarization.md)** - Compress long conversations into AI-generated summaries
|
||||
- **[File Context](docs/file-context.md)** - Attach or link files for better context
|
||||
- Automatic syncing with open editor files (optional)
|
||||
- Extended thinking mode (Claude, other providers in plan) - Enable deeper reasoning for complex tasks
|
||||
- Extended thinking / reasoning mode - shows streamed chain-of-thought for reasoning models (Claude, Google, OpenAI Responses, and OpenAI-compatible endpoints returning `reasoning_content` such as DeepSeek, Qwen, LM Studio, OpenRouter)
|
||||
|
||||
### Quick Refactoring
|
||||
- Inline code refactoring directly in the editor with AI assistance
|
||||
- Selection-based improvements with instant code replacement
|
||||
- Built-in quick actions (repeat, improve, alternative)
|
||||
- **Custom instructions library** with search and autocomplete
|
||||
- Create, edit, and manage reusable refactoring templates
|
||||
- Combine base instructions with specific details
|
||||
- **[Learn more](docs/quick-refactoring.md)**
|
||||
|
||||
### Tools & Function Calling
|
||||
- Read project files
|
||||
- List and search in project
|
||||
- Access linter/compiler issues
|
||||
- Enabled by default (can be disabled)
|
||||
|
||||
Chat and Quick Refactor can call tools to inspect and modify your project. Each tool can be individually enabled/disabled in settings.
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `list_project_files` | List files in the active project(s) |
|
||||
| `find_file` | Find a file by name or partial path |
|
||||
| `read_file` | Read file contents (project or absolute path) |
|
||||
| `search_project` | Grep / symbol search across project sources |
|
||||
| `create_new_file` | Create a new empty file on disk |
|
||||
| `edit_file` | Replace content in a file (old → new) |
|
||||
| `build_project` | Build the active project and return compiler output |
|
||||
| `get_issues_list` | Read current linter / compiler diagnostics |
|
||||
| `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.
|
||||
|
||||
- **Enable** in `Tools → Options → QodeAssist → MCP Server`
|
||||
- **Transport**: HTTP + SSE by default; a stdio bridge is provided for clients that only speak stdio (e.g. Claude Desktop)
|
||||
- **Ready-to-copy snippets** for Claude Code, VS Code, and the bridge are available via the "Show connection instructions" button in settings
|
||||
|
||||
### MCP Client Hub
|
||||
|
||||
QodeAssist can also act as an **MCP client**, connecting to external MCP servers and making their tools available to Chat and Quick Refactor alongside the built-in ones.
|
||||
|
||||
- **Configure** servers in `Tools → Options → QodeAssist → MCP Client`
|
||||
- **Transports**: stdio and HTTP/SSE
|
||||
- **Limitation**: authenticated MCP servers (OAuth / token-protected) are **not supported yet** — only servers that accept unauthenticated local connections work for now
|
||||
|
||||
## Context Layers
|
||||
|
||||
@@ -241,22 +402,24 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
|
||||
│ CHAT ASSISTANT │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 1. System Prompt (from Chat Assistant Settings) │
|
||||
│ 2. Project Rules: │
|
||||
│ 2. Agent Role (optional, from role selector): │
|
||||
│ └─ Role-specific system prompt (Developer, Reviewer, custom) │
|
||||
│ 3. Project Rules: │
|
||||
│ ├─ .qodeassist/rules/common/*.md │
|
||||
│ └─ .qodeassist/rules/chat/*.md │
|
||||
│ 3. File Context (optional): │
|
||||
│ 4. File Context (optional): │
|
||||
│ ├─ Attached files (manual) │
|
||||
│ ├─ Linked files (persistent) │
|
||||
│ └─ Open editor files (if auto-sync enabled) │
|
||||
│ 4. Tool Definitions (if enabled): │
|
||||
│ 5. Tool Definitions (if enabled): │
|
||||
│ ├─ ReadProjectFileByName │
|
||||
│ ├─ ListProjectFiles │
|
||||
│ ├─ SearchInProject │
|
||||
│ └─ GetIssuesList │
|
||||
│ 5. Conversation History │
|
||||
│ 6. User Message │
|
||||
│ 6. Conversation History │
|
||||
│ 7. User Message │
|
||||
│ │
|
||||
│ Final Prompt: [System: SystemPrompt + Rules + Tools] │
|
||||
│ Final Prompt: [System: SystemPrompt + AgentRole + Rules + Tools] │
|
||||
│ [History: Previous messages] │
|
||||
│ [User: FileContext + UserMessage] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -302,6 +465,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
|
||||
|
||||
- **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure
|
||||
- **System Prompts** are configured independently for each feature in Settings
|
||||
- **Agent Roles** add role-specific prompts on top of the base system prompt (Chat only)
|
||||
- **FIM vs Non-FIM models** for code completion use different System Prompts:
|
||||
- FIM models: Direct completion prompt
|
||||
- Non-FIM models: Prompt includes response formatting instructions
|
||||
@@ -309,14 +473,14 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
|
||||
- **Custom Instructions** provide reusable templates that can be augmented with specific details
|
||||
- **Tool Calling** is available for Chat and Quick Refactor when enabled
|
||||
|
||||
See [Project Rules Documentation](docs/project-rules.md) and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
|
||||
See [Agent Roles Guide](docs/agent-roles.md) and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
|
||||
|
||||
## QtCreator Version Compatibility
|
||||
|
||||
| Qt Creator Version | QodeAssist Version |
|
||||
|-------------------|-------------------|
|
||||
| 17.0.0+ | 0.6.0 - 0.x.x |
|
||||
| 16.0.2 | 0.5.13 - 0.x.x |
|
||||
| 16.0.2 | 0.5.13 - 0.9.6 |
|
||||
| 16.0.1 | 0.5.7 - 0.5.13 |
|
||||
| 16.0.0 | 0.5.2 - 0.5.6 |
|
||||
| 15.0.1 | 0.4.8 - 0.5.1 |
|
||||
@@ -351,25 +515,29 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
|
||||
|
||||
## Development Progress
|
||||
|
||||
- [x] Code completion functionality
|
||||
- [x] Chat assistant with multiple panels
|
||||
- [x] Code completion (FIM and chat models)
|
||||
- [x] Chat assistant (side / bottom / detached panels)
|
||||
- [x] Quick refactoring with custom-instructions library
|
||||
- [x] Diff sharing with models
|
||||
- [x] Tools/function calling support
|
||||
- [x] Project-specific rules
|
||||
- [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)
|
||||
- [ ] Full project source sharing
|
||||
- [ ] Additional provider support
|
||||
- [ ] MCP (Model Context Protocol) support
|
||||
|
||||
## Support the development of QodeAssist
|
||||
If you find QodeAssist helpful, there are several ways you can support the project:
|
||||
|
||||
1. **Report Issues**: If you encounter any bugs or have suggestions for improvements, please [open an issue](https://github.com/Palm1r/qodeassist/issues) on our GitHub repository.
|
||||
|
||||
2. **Contribute**: Feel free to submit pull requests with bug fixes or new features.
|
||||
2. **Contribute**: Feel free to submit pull requests with bug fixes or new features. The easiest contribution is an agent preset for a provider or model you use — it's a single TOML file, no C++ required; see [Contributing your agent](docs/creating-agents.md#contributing-your-agent-to-qodeassist).
|
||||
|
||||
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`
|
||||
@@ -377,6 +545,11 @@ If you find QodeAssist helpful, there are several ways you can support the proje
|
||||
|
||||
Every contribution, no matter how small, is greatly appreciated and helps keep the project alive!
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **[LLMQore](https://github.com/Palm1r/llmqore)** — the standalone LLM-core library extracted from QodeAssist, reusable in other Qt/C++ projects
|
||||
- **[QodeAssistUpdater](https://github.com/Palm1r/QodeAssistUpdater)** — CLI installer/updater for the plugin
|
||||
|
||||
## How to Build
|
||||
|
||||
### Prerequisites
|
||||
@@ -407,6 +580,10 @@ cmake --build .
|
||||
|
||||
## For Contributors
|
||||
|
||||
### Adding an agent preset
|
||||
|
||||
New provider/model presets are plain TOML — extend a provider base, register the file in `agents.qrc`, and the test suite validates it automatically. Step-by-step guide: [docs/creating-agents.md](docs/creating-agents.md#contributing-your-agent-to-qodeassist).
|
||||
|
||||
### Code Style
|
||||
|
||||
- **QML**: Follow [QML Coding Guide](https://github.com/Furkanzmc/QML-Coding-Guide) by @Furkanzmc
|
||||
@@ -417,6 +594,45 @@ cmake --build .
|
||||
|
||||
For detailed development guidelines, architecture patterns, and best practices, see the [project workspace rules](.cursor/rules.mdc).
|
||||
|
||||
## License
|
||||
|
||||
QodeAssist is licensed under the **GNU General Public License v3.0**
|
||||
(see [`LICENSE`](LICENSE)), with **additional attribution terms under
|
||||
GPLv3 Section 7(b)**.
|
||||
|
||||
You are free to use, modify, and redistribute QodeAssist under GPL-3.0,
|
||||
but you **must preserve** the original author attribution, copyright
|
||||
notices, and project identification — including in source file headers,
|
||||
the plugin metadata (`QodeAssist.json.in`), and the About dialog or
|
||||
equivalent user-facing identification. Modified versions must be clearly
|
||||
marked as different from the original.
|
||||
|
||||
### Commercial licensing
|
||||
|
||||
QodeAssist is also available under a separate commercial license for use
|
||||
in proprietary or closed-source products without GPL-3.0 obligations.
|
||||
For commercial licensing inquiries, contact **palm1r-github-dev@pm.me**.
|
||||
|
||||
### Qt Creator components and attributions
|
||||
|
||||
QodeAssist is a plugin for Qt Creator and incorporates certain components
|
||||
(plugin templates, API headers, and related boilerplate) originating from
|
||||
Qt Creator, which are copyright (C) The Qt Company Ltd.
|
||||
|
||||
These components are provided by The Qt Company under the GNU General
|
||||
Public License version 3, annotated with **The Qt Company GPL Exception
|
||||
1.0**. This exception permits the development and distribution of Qt
|
||||
Creator plugins under licenses of the plugin author's own choosing,
|
||||
notwithstanding the GPL's general linking requirements. It is this
|
||||
exception that allows QodeAssist to be offered under both GPL-3.0 and a
|
||||
separate commercial license.
|
||||
|
||||
The original copyright and license notices of The Qt Company are
|
||||
preserved in the relevant source files and must not be removed.
|
||||
|
||||
For Qt Creator's licensing terms, see
|
||||
[LICENSE.GPL3-EXCEPT](https://github.com/qt-creator/qt-creator/blob/master/LICENSES/LICENSE.GPL3-EXCEPT).
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
100
RefactorContextHelper.hpp
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QTextCursor>
|
||||
#include <QTextBlock>
|
||||
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <utils/textutils.h>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
struct RefactorContext
|
||||
{
|
||||
QString originalText;
|
||||
QString textBeforeCursor;
|
||||
QString textAfterCursor;
|
||||
QString contextBefore;
|
||||
QString contextAfter;
|
||||
int startPos{0};
|
||||
int endPos{0};
|
||||
bool isInsertion{false};
|
||||
};
|
||||
|
||||
class RefactorContextHelper
|
||||
{
|
||||
public:
|
||||
static RefactorContext extractContext(TextEditor::TextEditorWidget *editor,
|
||||
const Utils::Text::Range &range,
|
||||
int contextLinesBefore = 3,
|
||||
int contextLinesAfter = 3)
|
||||
{
|
||||
RefactorContext ctx;
|
||||
if (!editor) {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
QTextDocument *doc = editor->document();
|
||||
ctx.startPos = range.begin.toPositionInDocument(doc);
|
||||
ctx.endPos = range.end.toPositionInDocument(doc);
|
||||
ctx.isInsertion = (ctx.startPos == ctx.endPos);
|
||||
|
||||
if (!ctx.isInsertion) {
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(ctx.startPos);
|
||||
cursor.setPosition(ctx.endPos, QTextCursor::KeepAnchor);
|
||||
ctx.originalText = cursor.selectedText();
|
||||
ctx.originalText.replace(QChar(0x2029), "\n");
|
||||
} else {
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(ctx.startPos);
|
||||
|
||||
int posInBlock = cursor.positionInBlock();
|
||||
cursor.movePosition(QTextCursor::StartOfBlock);
|
||||
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock);
|
||||
ctx.textBeforeCursor = cursor.selectedText();
|
||||
|
||||
cursor.setPosition(ctx.startPos);
|
||||
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
ctx.textAfterCursor = cursor.selectedText();
|
||||
}
|
||||
|
||||
ctx.contextBefore = extractContextLines(doc, ctx.startPos, contextLinesBefore, true);
|
||||
ctx.contextAfter = extractContextLines(doc, ctx.endPos, contextLinesAfter, false);
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private:
|
||||
static QString extractContextLines(QTextDocument *doc, int position, int lineCount, bool before)
|
||||
{
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(position);
|
||||
QTextBlock currentBlock = cursor.block();
|
||||
|
||||
QStringList lines;
|
||||
|
||||
if (before) {
|
||||
QTextBlock block = currentBlock.previous();
|
||||
for (int i = 0; i < lineCount && block.isValid(); ++i) {
|
||||
lines.prepend(block.text());
|
||||
block = block.previous();
|
||||
}
|
||||
} else {
|
||||
QTextBlock block = currentBlock.next();
|
||||
for (int i = 0; i < lineCount && block.isValid(); ++i) {
|
||||
lines.append(block.text());
|
||||
block = block.next();
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "RefactorSuggestion.hpp"
|
||||
#include "LLMSuggestion.hpp"
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "RefactorSuggestionHoverHandler.hpp"
|
||||
#include "RefactorSuggestion.hpp"
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https:
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "FlowEditor.hpp"
|
||||
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
* QodeAssist is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* QodeAssist is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with QodeAssist. If not, see <https:
|
||||
*/
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "FlowItem.hpp"
|
||||
|
||||
namespace QodeAssist::TaskFlow {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QQuickItem>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "FlowsModel.hpp"
|
||||
|
||||
#include "FlowManager.hpp"
|
||||
|
||||