Compare commits

..

141 Commits

Author SHA1 Message Date
6c1d9ddc0e Upgrade plugin to 0.8.1 2025-11-03 18:07:29 +01:00
6ff6901421 fix: Remove check empty file for edit tool 2025-11-03 18:06:51 +01:00
a2f3ae4f64 fix: Changes to top of file 2025-11-03 15:01:51 +01:00
6937b48fbf fix: Creating dir for new file 2025-11-03 13:56:25 +01:00
c6e77c59d3 refactor: remove hardcoded tools guildelines 2025-11-03 10:07:47 +01:00
655471aec6 chore: Upgrade plugin to 0.8.0 2025-11-03 09:14:47 +01:00
af90d3cad2 refactor: Move build tools to experimental tools 2025-11-03 09:01:20 +01:00
9b90aaa06e feat: Add edit file tool (#249)
* feat: Add edit file tool
* feat: Add icons for action buttons
2025-11-03 08:56:52 +01:00
e7110810f8 fix: Clear connection before cancel 2025-11-01 20:51:01 +01:00
1848d44503 fix: Chat mode default value 2025-10-31 23:23:08 +01:00
db82fb08e8 feat: Add chat-agent switcher in chat ui (#247)
* feat: Add chat-agent switcher in chat ui

fix: qml errors

refactor: Change top bar layout

fix: default value

* fix: update github action for qtc
2025-10-31 16:09:38 +01:00
9117572f82 feat: Add Text slider button 2025-10-31 09:52:22 +01:00
a143cc8e20 fix: Add ctrl+enter shortcut 2025-10-31 09:13:40 +01:00
eae2b748d5 fix: Chat items width 2025-10-31 09:05:17 +01:00
64bca47290 feat: Add support QtCreator 18 (#246) 2025-10-31 04:08:37 +01:00
531fce96b5 fix: Build QString() compilation error 2025-10-29 01:15:50 +01:00
e7e437590a fix: Build error and tool guideline 2025-10-29 01:09:14 +01:00
00b7287e08 refactor: Back use tools by default and disabling auto apply 2025-10-29 01:01:51 +01:00
5a49a2e7eb refactor: Optimize searching tools
refactor: Merge read and find tool
2025-10-29 00:56:53 +01:00
3b56c1f07a feat: Add build tool 2025-10-28 23:50:44 +01:00
d483ca372d revert: Remove edit file tool (#245) 2025-10-28 16:39:49 +01:00
dfd9979450 refactor: Optimize tool guidelines and disable tools by default 2025-10-26 21:04:37 +01:00
6cb0b14b18 feat: Add log to search tool 2025-10-26 11:57:37 +01:00
43b64b9166 refactor: Simplified edit tool (#242)
refactor: Re-work edit file tool
2025-10-26 11:47:16 +01:00
608103b92e feat: Popup to show current project rules (#241)
* feat: Popup to show current project rules
* feat: Add icon to show project rules button
* feat: Add counter for active rules
2025-10-23 21:41:59 +02:00
e1025df21e feat: Add tool for creating file 2025-10-23 16:32:40 +02:00
fad2453dbe fix: Remove using tools from QuickRefactoring feature 2025-10-23 16:16:47 +02:00
5e1530715c fix: QString() issue in linux build 2025-10-23 16:14:28 +02:00
dfac209c23 feat: Add multiply reading to read files tool 2025-10-23 15:47:10 +02:00
cab8718979 refactor: Find symbol tool return only url and line 2025-10-23 15:07:22 +02:00
5dc28fc1ad refactor: Improve tool guidelines 2025-10-23 15:06:15 +02:00
1122332423 fix: Selection for code changes 2025-10-22 09:19:32 +02:00
7e878cdbf8 fix: Add tool exception for logging purpose 2025-10-21 00:51:42 +02:00
c95b20d6d4 fix: Override file edit tool another assistant message 2025-10-21 00:27:47 +02:00
70c610997a feat: Add settings for read files only from current project 2025-10-21 00:21:53 +02:00
8a4bf54fff feat: Improve tools guidelines 2025-10-20 19:25:21 +02:00
db7da29fa4 fix: Missed QString linux compilation error 2025-10-20 18:52:01 +02:00
a0a76f2665 fix: QString compilation 2025-10-20 18:45:23 +02:00
56354e8d87 feat: Add find file tool 2025-10-20 18:38:12 +02:00
b7322be00c fix: Read file by path description 2025-10-20 18:23:18 +02:00
254fac246d feat: Add settings for auto apply changes 2025-10-20 18:10:21 +02:00
0365018834 fix: Change parameter for read file tool 2025-10-20 16:17:45 +02:00
755be518be fix: Empty context for empty file 2025-10-20 13:52:13 +02:00
fe82b48bef feat: Add find symbol tool
* improve other tools for reading context
2025-10-20 12:32:03 +02:00
8a338ecb69 feat: Add file suggestion edit tool and chat UI (#240)
* feat: Add settings for write to system tool access
2025-10-20 11:48:18 +02:00
238ca00227 Update README.md 2025-10-15 13:28:02 +02:00
18fb2b530f chore: Upgrade plugin to 0.7.1 2025-10-14 02:35:52 +02:00
f0d2e42680 feat: Add support QtCreator 17.0.2 (#239)
feat: Add support QtC 17.0.2
2025-10-14 02:19:14 +02:00
ff0f994ec6 feat: Add project-specific rules support 2025-10-14 01:53:44 +02:00
45df27e749 feat: Add tool for reading issues tab 2025-10-13 18:33:17 +02:00
02863003a9 doc: Added tools info to README.md 2025-10-12 13:29:47 +02:00
002b8e01e5 chore: Upgrade plugin to 0.7.0 2025-10-12 12:28:24 +02:00
f54d1185aa feat: Improve context menu for tool results in chat 2025-10-12 12:16:37 +02:00
bcb0c6f761 fix: Copy selected in code block instead all 2025-10-12 12:03:12 +02:00
d285ab6117 fix: Improve tool handler tools execution 2025-10-12 11:18:20 +02:00
5ae6f9e3bf feat: Add searching tool 2025-10-12 04:25:56 +02:00
fb5903e44f fix: Remove unnecessary log 2025-10-12 03:57:40 +02:00
ce66c8e4f7 feat: Add tools permissions (#238) 2025-10-12 03:56:05 +02:00
5f094887e7 refactor: remove navigation panel 2025-10-12 02:33:21 +02:00
69d9af1a97 feat: Add tooling support to google provider (#237) 2025-10-11 19:46:27 +02:00
86b52bf858 feat: Add context menu to input field and text blocks 2025-10-11 18:35:19 +02:00
cac6068ee7 refactor: Fix copy button and add context menu to code block 2025-10-11 18:23:02 +02:00
8d495dd1bf feat: Add navigation panel for messages 2025-10-11 18:02:08 +02:00
906c161729 feat: Add ollama support tooling (#236) 2025-10-11 10:42:31 +02:00
ebd71daf3d fix: Cleanup accumulated text in one request 2025-10-10 16:45:23 +02:00
84770abb20 fix: Remove duplicate enum 2025-10-10 13:17:16 +02:00
b4e8bdf6da fix: Handling request error on provider error 2025-10-10 10:53:06 +02:00
d2b28093a6 feat: Improve showing tools in chat (#235) 2025-10-10 10:03:22 +02:00
bde58fb9aa Update FUNDING.yml 2025-10-02 13:58:05 +02:00
d4b6f8976b Update FUNDING.yml 2025-10-02 13:56:30 +02:00
cd08b5d919 feat: Add OpenAI compatible providers tooling support (#234)
* remove old providers message handler
2025-10-01 17:23:05 +02:00
f6de03f601 feat: Add llama.cpp tooling support 2025-10-01 16:37:44 +02:00
1a08eebe92 feat: Add Mistral AI tooling support 2025-10-01 15:58:45 +02:00
ea4f8b9df9 feat: Freeze commit hash for gh actions (#233) 2025-10-01 15:32:41 +02:00
7f77f7175d feat: Add tooling support for LM Studio 2025-10-01 12:33:12 +02:00
bed42f9098 feat: Add OpenAI tooling support (#232) 2025-10-01 00:58:54 +02:00
10b924d78a Feat: Add Claude tools support to plugin (#231)
* feat: Add settings for handle using tools in chat
* feat: Add Claude tools support
* fix: Add ai ignore to read project files list tool
* fix: Add ai ignore to read project file by name tool
* fix: Add ai ignore to read current opened files tool
2025-09-30 23:19:46 +02:00
8aa37c5c8c refactor: Move Message type enum to separate header 2025-09-30 19:37:46 +02:00
f8b87da2ca refactor: Remove inja submodule 2025-09-30 19:25:34 +02:00
7663bd34af refactor: Move UI controls to own lib 2025-09-29 18:55:51 +02:00
a52c86c6f0 fix: remove Cmake dev variable 2025-09-29 17:54:35 +02:00
ac53296e85 fix: Change behavior of cancel request
*now cancel request cancel all requests
2025-09-28 15:28:01 +02:00
c688cba3dd fix: Change handling shortcuts for handling copy-paste 2025-09-28 15:12:55 +02:00
ff750c271a feat: Add list project files tool 2025-09-23 00:09:56 +02:00
d0f8c1098f feat: Add tools, fabric and executable handler (#230) 2025-09-22 12:36:13 +02:00
5cde6ffac7 feat: Add tool interface and factory 2025-09-17 22:24:31 +02:00
8c6f1e514b fix: Fully qualified for Provider signals and slots 2025-09-17 20:29:53 +02:00
99cd79aac8 refactor: Add request id as type 2025-09-17 20:27:49 +02:00
d2b6c11569 fix: Reset for char renderer 2025-09-17 19:43:20 +02:00
ec1b5bdf5f refactor: Remove non-streaming support (#229) 2025-09-17 19:38:27 +02:00
561661b476 fix: Compatibility problem with nvenc on windows
* change default chat render to software on windows
2025-09-17 10:20:19 +02:00
76309be0a6 Refactor llm providers to use internal http client (#227)
* refactor: Move http client into provider

* refactor: Rework ollama provider for work with internal http client

* refactor: Rework LM Studio provider to work with internal http client

* refactor: Rework Mistral AI to work with internal http client

* fix: Replace url and header to QNetworkRequest

* refactor: Rework Google provider to use internal http client

* refactor: OpenAI compatible providers switch to use internal http client

* fix: Remove m_requestHandler from tests

* refactor: Remove old handleData method

* fix: Remove LLMClientInterfaceTest
2025-09-03 10:56:05 +02:00
5969d530bd chore: Upgrade plugin to 0.6.2 2025-09-01 00:51:03 +02:00
809f1c6614 feat: Add support QtCreator 17.0.1 (#225)
Add support 17.0.1 instead 17.0.0
2025-09-01 00:49:52 +02:00
851e681cf5 feat: Add new http client 2025-08-30 18:34:10 +02:00
f2f3b7cce0 doc: Add hotkey to close chat view 2025-08-20 09:31:27 +02:00
5b7a9b681c doc: Update chat description in README.md 2025-08-19 11:41:20 +02:00
29af277139 doc: Added chat and hotkeys description 2025-08-18 12:29:03 +02:00
a45786bd00 chore: Upgrade plugin to 0.6.1 2025-08-18 12:07:27 +02:00
695b35b510 feature: Add support Qwen3-coder model (#221)
Add support Qwen3-coder model
Rename template for old
2025-08-18 12:01:34 +02:00
5a23ab9c5a fix: Add compatible version in json info 2025-08-18 10:12:57 +02:00
c36dffea93 refactor: Add model output settings instead smartprocessing setting (#220) 2025-08-17 22:01:26 +02:00
4b7eed2779 fix: Change icon for close chat 2025-08-16 23:36:23 +02:00
88c11c4702 fix: Change status bar icon for show chat 2025-08-16 23:21:29 +02:00
543c79161d fix: Clean connection for workaround http2 windows problem (#219) 2025-08-15 10:17:40 +02:00
aa2edf5954 feature: Add popup window for chat
* feature: Add chat view via QQuickView
* feature: Update chat UI
* fix: Disable chat in navigation panel and bottom bar by default
2025-08-15 09:35:34 +02:00
894fec860a fix: Change LMStudio completion endpoint 2025-08-08 16:38:02 +02:00
e4324f8e80 refactor: Remove checking format on CI 2025-08-08 15:23:38 +02:00
6a0198ae9b refactor: Moved execute function to protected 2025-07-20 12:52:02 +02:00
e136d6056a feat: Add basic task flow run and edit (#212) 2025-07-12 23:44:34 +02:00
ff027b12af fix: Using Qt linguist tool in CI (#210)
* fix: Path to qt tools
* fix: Change TS dir variable for compatibility with Qt6.8
2025-07-04 01:11:51 +02:00
0bdf77f38d feat: Add possibility for translation 2025-07-04 00:40:05 +02:00
21814e8809 fix: Change qtc version typo in README.md 2025-06-24 14:22:30 +02:00
d732e2f9aa chore: Upgrade plugin version to 0.6.0 2025-06-18 20:04:36 +02:00
bf6d09a068 fix: Apply part of code suggestion (#203) 2025-06-18 20:00:18 +02:00
c3f2011c29 Add support QtCreator 17.0.0 (#202)
- removed support QtCreator 16.0.1, 16.0.2 instead
2025-06-18 19:51:51 +02:00
af3fdb58ff fix: Add custom endpoint to reset function 2025-05-18 20:06:46 +02:00
637a4d9d4c feat: Add custom providers endpoint (#188) 2025-05-17 09:21:06 +02:00
7e2345773f doc: Add support QtC 16.0.2 to README.md 2025-05-14 19:20:35 +02:00
14a5ddbdd8 chore: Upgrade plugin to 0.5.13 2025-05-14 19:09:16 +02:00
e178b7daa7 feat: Add multi QtCreator versions
* feat: Add multi qtc version
* feat Upgrade plugin to QtC 16.0.2
2025-05-14 16:06:15 +02:00
4b353d5091 doc: Update targets in README.md 2025-05-10 15:09:35 +02:00
f7ba7b95be doc: Fix ignore files api in README.md 2025-05-02 08:58:31 +02:00
6ae95fec45 doc: Added quick refactoring feature description to README.md 2025-05-02 08:31:34 +02:00
dad8ab2bf3 chore: Update plugin to 0.5.12 2025-05-01 15:38:05 +02:00
25a6983de0 refactor: Make connection more async (#182) 2025-05-01 15:35:33 +02:00
4e05abc7d2 feat: Add settings for text format 2025-05-01 00:01:44 +02:00
784529e344 feat: Add chat font settings (#180) 2025-04-30 22:44:59 +02:00
155153a763 fix: Optimize searching unreadable symbols for markdown 2025-04-30 21:23:43 +02:00
9225c0c1a9 fix: Check readable symbols for markdown 2025-04-29 21:55:44 +02:00
43adc95857 feat: Add a floating "copy" button 2025-04-28 09:25:39 +02:00
ee672f2cda refactor: Remove Chat preview scrollbar 2025-04-26 17:29:06 +02:00
a3edb8a577 chore: Upgrade plugin to 0.5.11 2025-04-24 21:46:32 +02:00
407d3b11c0 fix: Change maximum limit of chat tokens 2025-04-24 21:44:49 +02:00
285e739074 refactor: Change base text style render to markdown 2025-04-24 21:38:54 +02:00
f7e748ba7e chore: Upgrade plugin to 0.5.10 2025-04-24 03:24:00 +02:00
acb1306321 fix: Improve detect unclose codeblock 2025-04-24 03:21:19 +02:00
8b38ecc29b feat: Add Chat preview scroll bar 2025-04-24 02:54:21 +02:00
cfb364f033 fix: Correct removing latest item in messages list 2025-04-24 01:32:01 +02:00
2fe6850a06 refactor: Improve textsuggestion working 2025-04-24 01:25:45 +02:00
3e9506ca92 doc: Add description of ignoring files feature to README.md 2025-04-21 09:40:34 +02:00
207 changed files with 18366 additions and 1660 deletions

2
.github/FUNDING.yml vendored
View File

@ -12,4 +12,4 @@ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cl
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
custom: ['https://www.paypal.com/paypalme/palm1r', 'https://github.com/Palm1r/QodeAssist#support-the-development-of-qodeassist']

View File

@ -13,7 +13,7 @@
"Linux"
],
"license": "GPLv3",
"version": "0.5.8",
"version": "0.5.11",
"status": "draft",
"is_pack": false,
"released_at": null,
@ -55,8 +55,28 @@
},
{
"version": "0.5.8",
"is_latest": true,
"is_latest": false,
"released_at": "2025-04-17T10:00:00Z"
},
{
"version": "0.5.9",
"is_latest": false,
"released_at": "2025-04-21T10:00:00Z"
},
{
"version": "0.5.10",
"is_latest": false,
"released_at": "2025-04-24T10:00:00Z"
},
{
"version": "0.5.11",
"is_latest": false,
"released_at": "2025-04-24T21:00:00Z"
},
{
"version": "0.5.12",
"is_latest": true,
"released_at": "2025-05-01T17:00:00Z"
}
],
"icon": "https://github.com/user-attachments/assets/dc336712-83cb-440d-8761-8d0a31de898d",

View File

@ -12,16 +12,13 @@ on:
env:
PLUGIN_NAME: QodeAssist
QT_VERSION: 6.8.3
QT_CREATOR_VERSION: 16.0.1
QT_CREATOR_VERSION_INTERNAL: 16.0.1
MACOS_DEPLOYMENT_TARGET: "11.0"
CMAKE_VERSION: "3.29.6"
NINJA_VERSION: "1.12.1"
jobs:
build:
name: ${{ matrix.config.name }}
name: ${{ matrix.config.name }} (Qt ${{ matrix.qt_config.qt_version }}, QtC ${{ matrix.qt_config.qt_creator_version }})
runs-on: ${{ matrix.config.os }}
outputs:
tag: ${{ steps.git.outputs.tag }}
@ -47,12 +44,22 @@ jobs:
platform: mac_x64,
cc: "clang", cxx: "clang++"
}
qt_config:
- {
qt_version: "6.8.3",
qt_creator_version: "16.0.2"
}
- {
qt_version: "6.9.2",
qt_creator_version: "17.0.2"
}
- {
qt_version: "6.10.0",
qt_creator_version: "18.0.0"
}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955
- name: Checkout submodules
id: git
@ -61,11 +68,16 @@ jobs:
if (${{github.ref}} MATCHES "tags/v(.*)")
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${CMAKE_MATCH_1}")
else()
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${{github.run_id}}")
execute_process(
COMMAND git rev-parse --short HEAD
OUTPUT_VARIABLE short_sha
OUTPUT_STRIP_TRAILING_WHITESPACE
)
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${short_sha}")
endif()
- name: Download Ninja and CMake
uses: lukka/get-cmake@latest
uses: lukka/get-cmake@2ecc21724e5215b0e567bc399a2602d2ecb48541
with:
cmakeVersion: ${{ env.CMAKE_VERSION }}
ninjaVersion: ${{ env.NINJA_VERSION }}
@ -96,14 +108,19 @@ jobs:
id: qt
shell: cmake -P {0}
run: |
set(qt_version "$ENV{QT_VERSION}")
set(qt_version "${{ matrix.qt_config.qt_version }}")
set(qt_creator_version "${{ matrix.qt_config.qt_creator_version }}")
string(REPLACE "." "" qt_version_dotless "${qt_version}")
if ("${{ runner.os }}" STREQUAL "Windows")
set(url_os "windows_x86")
set(qt_package_arch_suffix "win64_msvc2022_64")
set(qt_dir_prefix "${qt_version}/msvc2022_64")
set(qt_package_suffix "-Windows-Windows_11_23H2-MSVC2022-Windows-Windows_11_23H2-X86_64")
if (qt_creator_version VERSION_GREATER_EQUAL "18.0.0")
set(qt_package_suffix "-Windows-Windows_11_24H2-MSVC2022-Windows-Windows_11_24H2-X86_64")
else()
set(qt_package_suffix "-Windows-Windows_11_23H2-MSVC2022-Windows-Windows_11_23H2-X86_64")
endif()
elseif ("${{ runner.os }}" STREQUAL "Linux")
set(url_os "linux_x64")
if (qt_version VERSION_LESS "6.7.0")
@ -112,12 +129,20 @@ jobs:
set(qt_package_arch_suffix "linux_gcc_64")
endif()
set(qt_dir_prefix "${qt_version}/gcc_64")
set(qt_package_suffix "-Linux-RHEL_8_10-GCC-Linux-RHEL_8_10-X86_64")
if (qt_creator_version VERSION_GREATER_EQUAL "18.0.0")
set(qt_package_suffix "-Linux-RHEL_9_4-GCC-Linux-RHEL_9_4-X86_64")
else()
set(qt_package_suffix "-Linux-RHEL_8_10-GCC-Linux-RHEL_8_10-X86_64")
endif()
elseif ("${{ runner.os }}" STREQUAL "macOS")
set(url_os "mac_x64")
set(qt_package_arch_suffix "clang_64")
set(qt_dir_prefix "${qt_version}/macos")
set(qt_package_suffix "-MacOS-MacOS_14-Clang-MacOS-MacOS_14-X86_64-ARM64")
if (qt_version VERSION_LESS "6.9.1")
set(qt_package_suffix "-MacOS-MacOS_14-Clang-MacOS-MacOS_14-X86_64-ARM64")
else()
set(qt_package_suffix "-MacOS-MacOS_15-Clang-MacOS-MacOS_15-X86_64-ARM64")
endif()
endif()
set(qt_base_url "https://download.qt.io/online/qtsdkrepository/${url_os}/desktop/qt6_${qt_version_dotless}/qt6_${qt_version_dotless}")
@ -140,7 +165,7 @@ jobs:
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ../${archive} WORKING_DIRECTORY qt6)
endfunction()
foreach(package qtbase qtdeclarative)
foreach(package qtbase qtdeclarative qttools)
downloadAndExtract(
"${qt_base_url}/qt.qt6.${qt_version_dotless}.${qt_package_arch_suffix}/${qt_package_version}${package}${qt_package_suffix}.7z"
${package}.7z
@ -174,10 +199,11 @@ jobs:
endif()
- name: Download Qt Creator
uses: qt-creator/install-dev-package@v1.2
uses: qt-creator/install-dev-package@1460787a21551eb3d867b0de30e8d3f1aadef5ac
with:
version: ${{ env.QT_CREATOR_VERSION }}
version: ${{ matrix.qt_config.qt_creator_version }}
unzip-to: 'qtcreator'
platform: ${{ matrix.config.platform }}
- name: Extract Qt Creator
id: qt_creator
@ -223,7 +249,7 @@ jobs:
COMMAND python
-u
"${{ steps.qt_creator.outputs.qtc_dir }}/${build_plugin_py}"
--name "$ENV{PLUGIN_NAME}-v${{ steps.git.outputs.tag }}-QtC$ENV{QT_CREATOR_VERSION}-${{ matrix.config.artifact }}"
--name "$ENV{PLUGIN_NAME}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}"
--src .
--build build
--qt-path "${{ steps.qt.outputs.qt_dir }}"
@ -239,74 +265,24 @@ jobs:
endif()
- name: Upload
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
path: ./${{ env.PLUGIN_NAME }}-v${{ steps.git.outputs.tag }}-QtC${{ env.QT_CREATOR_VERSION }}-${{ matrix.config.artifact }}.7z
name: ${{ env.PLUGIN_NAME}}-v${{ steps.git.outputs.tag }}-QtC${{ env.QT_CREATOR_VERSION }}-${{ matrix.config.artifact }}.7z
# The json is the same for all platforms, but we need to save one
- name: Upload plugin json
if: startsWith(matrix.config.os, 'ubuntu')
uses: actions/upload-artifact@v4
with:
name: ${{ env.PLUGIN_NAME }}-origin-json
path: ./build/build/${{ env.PLUGIN_NAME }}.json
path: ./${{ env.PLUGIN_NAME }}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}.7z
name: ${{ env.PLUGIN_NAME}}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}.7z
- name: Run unit tests
if: startsWith(matrix.config.os, 'ubuntu')
run: |
xvfb-run ./build/build/test/QodeAssistTest
update_json:
if: contains(github.ref, 'tags/v')
runs-on: ubuntu-22.04
needs: build
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Download the JSON file
uses: actions/download-artifact@v4
with:
name: ${{ env.PLUGIN_NAME }}-origin-json
path: ./${{ env.PLUGIN_NAME }}-origin
- name: Store Release upload_url
run: |
RELEASE_HTML_URL=$(echo "${{github.event.repository.html_url}}/releases/download/v${{ needs.build.outputs.tag }}")
echo "RELEASE_HTML_URL=${RELEASE_HTML_URL}" >> $GITHUB_ENV
- name: Run the Node.js script to update JSON
env:
QT_TOKEN: ${{ secrets.TOKEN }}
API_URL: ${{ secrets.API_URL }}
run: |
node .github/scripts/registerPlugin.js ${{ env.RELEASE_HTML_URL }} ${{ env.PLUGIN_NAME }} ${{ env.QT_CREATOR_VERSION }} ${{ env.QT_CREATOR_VERSION_INTERNAL }} ${{ env.QT_TOKEN }} ${{ env.API_URL }}
- name: Delete previous json artifacts
uses: geekyeggo/delete-artifact@v5
with:
name: ${{ env.PLUGIN_NAME }}*-json
- name: Upload the modified JSON file as an artifact
uses: actions/upload-artifact@v4
with:
name: plugin-json
path: .github/scripts/${{ env.PLUGIN_NAME }}.json
release:
if: contains(github.ref, 'tags/v')
runs-on: ubuntu-latest
needs: [build, update_json]
runs-on: ubuntu-22.04
needs: [build]
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: release-with-dirs
@ -317,7 +293,7 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@ -1,24 +0,0 @@
name: Check formatting
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang-format-19
- name: Check formatting
run: |
clang-format-19 --style=file -i $(git ls-files | fgrep .hpp)
clang-format-19 --style=file -i $(git ls-files | fgrep .cpp)
git diff --exit-code || exit 1

6
.gitignore vendored
View File

@ -73,4 +73,8 @@ CMakeLists.txt.user*
*.dll
*.exe
/build
/build
/.qodeassist
/.cursor
/.vscode
.qtc_clangd/compile_commands.json

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "3rdparty/inja"]
path = 3rdparty/inja
url = https://github.com/pantor/inja

1
3rdparty/inja vendored

Submodule 3rdparty/inja deleted from 384a6bef3f

View File

@ -11,9 +11,11 @@ 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 REQUIRED)
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Test LinguistTools REQUIRED)
find_package(GTest)
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES en)
# IDE_VERSION is defined by QtCreator package
string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" version_match ${IDE_VERSION})
set(QODEASSIST_QT_CREATOR_VERSION_MAJOR ${CMAKE_MATCH_1})
@ -35,6 +37,7 @@ add_definitions(
add_subdirectory(llmcore)
add_subdirectory(settings)
add_subdirectory(logger)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
add_subdirectory(context)
if(GTest_FOUND)
@ -47,6 +50,7 @@ add_qtc_plugin(QodeAssist
QtCreator::LanguageClient
QtCreator::TextEditor
QtCreator::ProjectExplorer
QtCreator::CppEditor
DEPENDS
Qt::Core
Qt::Gui
@ -55,6 +59,7 @@ add_qtc_plugin(QodeAssist
Qt::Network
QtCreator::ExtensionSystem
QtCreator::Utils
QtCreator::CPlusPlus
QodeAssistChatViewplugin
SOURCES
.github/workflows/build_cmake.yml
@ -73,7 +78,7 @@ add_qtc_plugin(QodeAssist
templates/StarCoder2Fim.hpp
# templates/DeepSeekCoderFim.hpp
# templates/CustomFimTemplate.hpp
templates/Qwen.hpp
templates/Qwen25CoderFIM.hpp
templates/OpenAICompatible.hpp
templates/Llama3.hpp
templates/ChatML.hpp
@ -82,6 +87,7 @@ add_qtc_plugin(QodeAssist
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
@ -108,6 +114,22 @@ add_qtc_plugin(QodeAssist
widgets/EditorChatButtonHandler.hpp widgets/EditorChatButtonHandler.cpp
widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp
QuickRefactorHandler.hpp QuickRefactorHandler.cpp
tools/ToolsFactory.hpp tools/ToolsFactory.cpp
tools/ReadVisibleFilesTool.hpp tools/ReadVisibleFilesTool.cpp
tools/ToolHandler.hpp tools/ToolHandler.cpp
tools/ListProjectFilesTool.hpp tools/ListProjectFilesTool.cpp
tools/ToolsManager.hpp tools/ToolsManager.cpp
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/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp
tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.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
)
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
@ -126,3 +148,10 @@ if (QtCreatorExecutable)
)
set_target_properties(RunQtCreator PROPERTIES FOLDER "qtc_runnable")
endif()
#TODO change to TS_OUTPUT_DIRECTORY after removing Qt6.8
qt_add_translations(TARGETS QodeAssist
TS_FILE_DIR ${CMAKE_CURRENT_LIST_DIR}/resources/translations
RESOURCE_PREFIX "/translations"
LUPDATE_OPTIONS -no-obsolete
)

View File

@ -6,17 +6,22 @@ qt_policy(SET QTP0004 NEW)
qt_add_qml_module(QodeAssistChatView
URI ChatView
VERSION 1.0
DEPENDENCIES QtQuick
DEPENDENCIES
QtQuick
QML_FILES
qml/RootItem.qml
qml/ChatItem.qml
qml/Badge.qml
qml/dialog/CodeBlock.qml
qml/dialog/TextBlock.qml
qml/controls/QoAButton.qml
qml/parts/TopBar.qml
qml/parts/BottomBar.qml
qml/parts/AttachedFilesPlace.qml
qml/parts/Toast.qml
qml/ToolStatusItem.qml
qml/FileEditItem.qml
qml/parts/RulesViewer.qml
qml/parts/FileEditsActionBar.qml
RESOURCES
icons/attach-file-light.svg
icons/attach-file-dark.svg
@ -24,6 +29,20 @@ qt_add_qml_module(QodeAssistChatView
icons/close-light.svg
icons/link-file-light.svg
icons/link-file-dark.svg
icons/load-chat-dark.svg
icons/save-chat-dark.svg
icons/clean-icon-dark.svg
icons/file-in-system.svg
icons/window-lock.svg
icons/window-unlock.svg
icons/chat-icon.svg
icons/chat-pause-icon.svg
icons/rules-icon.svg
icons/open-in-editor.svg
icons/apply-changes-button.svg
icons/undo-changes-button.svg
icons/reject-changes-button.svg
SOURCES
ChatWidget.hpp ChatWidget.cpp
ChatModel.hpp ChatModel.cpp
@ -32,6 +51,9 @@ qt_add_qml_module(QodeAssistChatView
MessagePart.hpp
ChatUtils.h ChatUtils.cpp
ChatSerializer.hpp ChatSerializer.cpp
ChatView.hpp ChatView.cpp
ChatData.hpp
)
target_link_libraries(QodeAssistChatView
@ -45,6 +67,8 @@ target_link_libraries(QodeAssistChatView
LLMCore
QodeAssistSettings
Context
QodeAssistUIControlsplugin
QodeAssistLogger
)
target_include_directories(QodeAssistChatView

32
ChatView/ChatData.hpp Normal file
View File

@ -0,0 +1,32 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QtQmlIntegration>
namespace QodeAssist::Chat {
Q_NAMESPACE
QML_NAMED_ELEMENT(MessagePartType)
enum class MessagePartType { Code, Text };
Q_ENUM_NS(MessagePartType)
} // namespace QodeAssist::Chat

View File

@ -19,10 +19,14 @@
#include "ChatModel.hpp"
#include <utils/aspects.h>
#include <QtCore/qjsonobject.h>
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtQml>
#include "ChatAssistantSettings.hpp"
#include "Logger.hpp"
#include "context/ChangesManager.h"
namespace QodeAssist::Chat {
@ -36,6 +40,21 @@ ChatModel::ChatModel(QObject *parent)
&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);
}
int ChatModel::rowCount(const QModelIndex &parent) const
@ -91,7 +110,8 @@ void ChatModel::addMessage(
}
}
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id) {
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;
@ -102,6 +122,45 @@ void ChatModel::addMessage(
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));
}
}
}
}
}
}
}
@ -131,17 +190,47 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
QString textBetween
= content.mid(lastIndex, match.capturedStart() - lastIndex).trimmed();
if (!textBetween.isEmpty()) {
parts.append({MessagePart::Text, textBetween, ""});
MessagePart part;
part.type = MessagePartType::Text;
part.text = textBetween;
parts.append(part);
}
}
parts.append({MessagePart::Code, match.captured(2).trimmed(), match.captured(1)});
MessagePart codePart;
codePart.type = MessagePartType::Code;
codePart.text = match.captured(2).trimmed();
codePart.language = match.captured(1);
parts.append(codePart);
lastIndex = match.capturedEnd();
}
if (lastIndex < content.length()) {
QString remainingText = content.mid(lastIndex).trimmed();
if (!remainingText.isEmpty()) {
parts.append({MessagePart::Text, remainingText, ""});
QRegularExpression unclosedBlockRegex("```(\\w*)\\n?([\\s\\S]*)$");
auto unclosedMatch = unclosedBlockRegex.match(remainingText);
if (unclosedMatch.hasMatch()) {
QString beforeCodeBlock = remainingText.left(unclosedMatch.capturedStart()).trimmed();
if (!beforeCodeBlock.isEmpty()) {
MessagePart part;
part.type = MessagePartType::Text;
part.text = beforeCodeBlock;
parts.append(part);
}
MessagePart codePart;
codePart.type = MessagePartType::Code;
codePart.text = unclosedMatch.captured(2).trimmed();
codePart.language = unclosedMatch.captured(1);
parts.append(codePart);
} else if (!remainingText.isEmpty()) {
MessagePart part;
part.type = MessagePartType::Text;
part.text = remainingText;
parts.append(part);
}
}
@ -162,6 +251,9 @@ QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) con
case ChatRole::Assistant:
role = "assistant";
break;
case ChatRole::Tool:
case ChatRole::FileEdit:
continue;
default:
continue;
}
@ -202,11 +294,189 @@ void ChatModel::resetModelTo(int index)
if (index < 0 || index >= m_messages.size())
return;
if (index < m_messages.size() - 1) {
if (index < m_messages.size()) {
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
m_messages.remove(index, m_messages.size() - index);
endRemoveRows();
}
}
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;
}
}
if (!toolMessageFound) {
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
.arg(requestId, toolId));
}
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));
}
}
}
}
void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent)
{
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;
}
}
}
void ChatModel::setLoadingFromHistory(bool loading)
{
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;
}
}
}
}
}
}
} // namespace QodeAssist::Chat

View File

@ -37,10 +37,11 @@ class ChatModel : public QAbstractListModel
QML_ELEMENT
public:
enum ChatRole { System, User, Assistant };
enum ChatRole { System, User, Assistant, Tool, FileEdit };
Q_ENUM(ChatRole)
enum Roles { RoleType = Qt::UserRole, Content, Attachments };
Q_ENUM(Roles)
struct Message
{
@ -75,12 +76,32 @@ public:
Q_INVOKABLE void resetModelTo(int index);
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 updateMessageContent(const QString &messageId, const QString &newContent);
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
signals:
void tokensThresholdChanged();
void modelReseted();
private slots:
void onFileEditApplied(const QString &editId);
void onFileEditRejected(const QString &editId);
void onFileEditArchived(const QString &editId);
private:
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage);
QVector<Message> m_messages;
bool m_loadingFromHistory = false;
};
} // namespace QodeAssist::Chat

View File

@ -29,16 +29,20 @@
#include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectmanager.h>
#include <texteditor/texteditor.h>
#include <utils/theme/theme.h>
#include <utils/utilsicons.h>
#include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp"
#include "GeneralSettings.hpp"
#include "ToolsSettings.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "context/ChangesManager.h"
#include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp"
#include "llmcore/RulesLoader.hpp"
namespace QodeAssist::Chat {
@ -47,6 +51,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
, m_chatModel(new ChatModel(this))
, m_promptProvider(LLMCore::PromptTemplateManager::instance())
, m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this))
, m_isRequestInProgress(false)
{
m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles();
connect(
@ -66,13 +71,21 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this,
&ChatRootView::autosave);
connect(m_clientInterface, &ClientInterface::messageReceivedCompletely, this, [this]() {
this->setRequestProgressStatus(false);
});
connect(
m_clientInterface,
&ClientInterface::messageReceivedCompletely,
this,
&ChatRootView::updateInputTokensCount);
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() { setRecentFilePath(QString{}); });
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() {
setRecentFilePath(QString{});
m_currentMessageRequestId.clear();
updateCurrentMessageEditsStats();
});
connect(this, &ChatRootView::attachmentFilesChanged, &ChatRootView::updateInputTokensCount);
connect(this, &ChatRootView::linkedFilesChanged, &ChatRootView::updateInputTokensCount);
connect(
@ -102,8 +115,94 @@ ChatRootView::ChatRootView(QQuickItem *parent)
}
}
});
connect(
&Settings::chatAssistantSettings().textFontFamily,
&Utils::BaseAspect::changed,
this,
&ChatRootView::textFamilyChanged);
connect(
&Settings::chatAssistantSettings().codeFontFamily,
&Utils::BaseAspect::changed,
this,
&ChatRootView::codeFamilyChanged);
connect(
&Settings::chatAssistantSettings().textFontSize,
&Utils::BaseAspect::changed,
this,
&ChatRootView::textFontSizeChanged);
connect(
&Settings::chatAssistantSettings().codeFontSize,
&Utils::BaseAspect::changed,
this,
&ChatRootView::codeFontSizeChanged);
connect(
&Settings::chatAssistantSettings().textFormat,
&Utils::BaseAspect::changed,
this,
&ChatRootView::textFormatChanged);
connect(m_clientInterface, &ClientInterface::errorOccurred, this, [this](const QString &error) {
this->setRequestProgressStatus(false);
m_lastErrorMessage = error;
emit lastErrorMessageChanged();
});
connect(m_clientInterface, &ClientInterface::requestStarted, this, [this](const QString &requestId) {
if (!m_currentMessageRequestId.isEmpty()) {
LOG_MESSAGE(QString("Clearing previous message requestId: %1").arg(m_currentMessageRequestId));
}
m_currentMessageRequestId = requestId;
LOG_MESSAGE(QString("New message request started: %1").arg(requestId));
updateCurrentMessageEditsStats();
});
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditAdded,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditApplied,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditRejected,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditUndone,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
connect(
&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditArchived,
this,
[this](const QString &) { updateCurrentMessageEditsStats(); });
updateInputTokensCount();
refreshRules();
connect(
ProjectExplorer::ProjectManager::instance(),
&ProjectExplorer::ProjectManager::startupProjectChanged,
this,
&ChatRootView::refreshRules);
QSettings appSettings;
m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool();
connect(
&Settings::toolsSettings().useTools,
&Utils::BaseAspect::changed,
this,
&ChatRootView::toolsSupportEnabledChanged);
}
ChatModel *ChatRootView::chatModel() const
@ -129,8 +228,9 @@ void ChatRootView::sendMessage(const QString &message)
}
}
m_clientInterface->sendMessage(message, m_attachmentFiles, m_linkedFiles);
m_clientInterface->sendMessage(message, m_attachmentFiles, m_linkedFiles, m_isAgentMode);
clearAttachmentFiles();
setRequestProgressStatus(true);
}
void ChatRootView::copyToClipboard(const QString &text)
@ -141,6 +241,7 @@ void ChatRootView::copyToClipboard(const QString &text)
void ChatRootView::cancelRequest()
{
m_clientInterface->cancelRequest();
setRequestProgressStatus(false);
}
void ChatRootView::clearAttachmentFiles()
@ -204,7 +305,10 @@ void ChatRootView::loadHistory(const QString &filePath)
} else {
setRecentFilePath(filePath);
}
m_currentMessageRequestId.clear();
updateInputTokensCount();
updateCurrentMessageEditsStats();
}
void ChatRootView::showSaveDialog()
@ -302,8 +406,7 @@ QString ChatRootView::getSuggestedFileName() const
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"));
fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
}
return fileName;
@ -454,6 +557,25 @@ void ChatRootView::openChatHistoryFolder()
QDesktopServices::openUrl(url);
}
void ChatRootView::openRulesFolder()
{
auto project = ProjectExplorer::ProjectManager::startupProject();
if (!project) {
return;
}
QString projectPath = project->projectDirectory().toFSPathString();
QString rulesPath = projectPath + "/.qodeassist/rules";
QDir dir(rulesPath);
if (!dir.exists()) {
dir.mkpath(".");
}
QUrl url = QUrl::fromLocalFile(dir.absolutePath());
QDesktopServices::openUrl(url);
}
void ChatRootView::updateInputTokensCount()
{
int inputTokens = m_messageTokensCount;
@ -552,4 +674,417 @@ bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath)
return false;
}
QString ChatRootView::textFontFamily() const
{
return Settings::chatAssistantSettings().textFontFamily.stringValue();
}
QString ChatRootView::codeFontFamily() const
{
return Settings::chatAssistantSettings().codeFontFamily.stringValue();
}
int ChatRootView::codeFontSize() const
{
return Settings::chatAssistantSettings().codeFontSize();
}
int ChatRootView::textFontSize() const
{
return Settings::chatAssistantSettings().textFontSize();
}
int ChatRootView::textFormat() const
{
return Settings::chatAssistantSettings().textFormat();
}
bool ChatRootView::isRequestInProgress() const
{
return m_isRequestInProgress;
}
void ChatRootView::setRequestProgressStatus(bool state)
{
if (m_isRequestInProgress == state)
return;
m_isRequestInProgress = state;
emit isRequestInProgressChanged();
}
QString ChatRootView::lastErrorMessage() const
{
return m_lastErrorMessage;
}
QVariantList ChatRootView::activeRules() const
{
return m_activeRules;
}
int ChatRootView::activeRulesCount() const
{
return m_activeRules.size();
}
QString ChatRootView::getRuleContent(int index)
{
if (index < 0 || index >= m_activeRules.size())
return QString();
return LLMCore::RulesLoader::loadRuleFileContent(
m_activeRules[index].toMap()["filePath"].toString());
}
void ChatRootView::refreshRules()
{
m_activeRules.clear();
auto project = LLMCore::RulesLoader::getActiveProject();
if (!project) {
emit activeRulesChanged();
emit activeRulesCountChanged();
return;
}
auto ruleFiles
= LLMCore::RulesLoader::getRuleFilesForProject(project, LLMCore::RulesContext::Chat);
for (const auto &ruleFile : ruleFiles) {
QVariantMap ruleMap;
ruleMap["filePath"] = ruleFile.filePath;
ruleMap["fileName"] = ruleFile.fileName;
ruleMap["category"] = ruleFile.category;
m_activeRules.append(ruleMap);
}
emit activeRulesChanged();
emit activeRulesCountChanged();
}
bool ChatRootView::isAgentMode() const
{
return m_isAgentMode;
}
void ChatRootView::setIsAgentMode(bool newIsAgentMode)
{
if (m_isAgentMode != newIsAgentMode) {
m_isAgentMode = newIsAgentMode;
QSettings settings;
settings.setValue("QodeAssist/Chat/AgentMode", newIsAgentMode);
emit isAgentModeChanged();
}
}
bool ChatRootView::toolsSupportEnabled() const
{
return Settings::toolsSettings().useTools();
}
void ChatRootView::applyFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
m_lastInfoMessage = QString("File edit applied successfully");
emit lastInfoMessageChanged();
updateFileEditStatus(editId, "applied");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to apply file edit")
: QString("Failed to apply file edit: %1").arg(edit.statusMessage);
emit lastErrorMessageChanged();
}
}
void ChatRootView::rejectFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
m_lastInfoMessage = QString("File edit rejected");
emit lastInfoMessageChanged();
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to reject file edit")
: QString("Failed to reject file edit: %1").arg(edit.statusMessage);
emit lastErrorMessageChanged();
}
}
void ChatRootView::undoFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
m_lastInfoMessage = QString("File edit undone successfully");
emit lastInfoMessageChanged();
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
m_lastErrorMessage = edit.statusMessage.isEmpty()
? QString("Failed to undo file edit")
: QString("Failed to undo file edit: %1").arg(edit.statusMessage);
emit lastErrorMessageChanged();
}
}
void ChatRootView::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()) {
m_lastErrorMessage = QString("File edit not found: %1").arg(editId);
emit lastErrorMessageChanged();
return;
}
Utils::FilePath filePath = Utils::FilePath::fromString(edit.filePath);
Core::IEditor *editor = Core::EditorManager::openEditor(filePath);
if (!editor) {
m_lastErrorMessage = QString("Failed to open file in editor: %1").arg(edit.filePath);
emit lastErrorMessageChanged();
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 ChatRootView::updateFileEditStatus(const QString &editId, const QString &status)
{
auto messages = m_chatModel->getChatHistory();
for (int i = 0; i < messages.size(); ++i) {
if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) {
QString content = messages[i].content;
const QString marker = "QODEASSIST_FILE_EDIT:";
int markerPos = content.indexOf(marker);
QString jsonStr = content;
if (markerPos >= 0) {
jsonStr = content.mid(markerPos + marker.length());
}
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject obj = doc.object();
obj["status"] = status;
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (!edit.statusMessage.isEmpty()) {
obj["status_message"] = edit.statusMessage;
}
QString updatedContent = marker + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact));
m_chatModel->updateMessageContent(editId, updatedContent);
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
}
break;
}
}
updateCurrentMessageEditsStats();
}
void ChatRootView::applyAllFileEditsForCurrentMessage()
{
if (m_currentMessageRequestId.isEmpty()) {
m_lastErrorMessage = QString("No active message with file edits");
emit lastErrorMessageChanged();
return;
}
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentMessageRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
.reapplyAllEditsForRequest(m_currentMessageRequestId, &errorMsg);
if (success) {
m_lastInfoMessage = QString("All file edits applied successfully");
emit lastInfoMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
} else {
m_lastErrorMessage = errorMsg.isEmpty()
? QString("Failed to apply some file edits")
: QString("Failed to apply some file edits:\n%1").arg(errorMsg);
emit lastErrorMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
}
updateCurrentMessageEditsStats();
}
void ChatRootView::undoAllFileEditsForCurrentMessage()
{
if (m_currentMessageRequestId.isEmpty()) {
m_lastErrorMessage = QString("No active message with file edits");
emit lastErrorMessageChanged();
return;
}
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentMessageRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
.undoAllEditsForRequest(m_currentMessageRequestId, &errorMsg);
if (success) {
m_lastInfoMessage = QString("All file edits undone successfully");
emit lastInfoMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
} else {
m_lastErrorMessage = errorMsg.isEmpty()
? QString("Failed to undo some file edits")
: QString("Failed to undo some file edits:\n%1").arg(errorMsg);
emit lastErrorMessageChanged();
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
}
updateCurrentMessageEditsStats();
}
void ChatRootView::updateCurrentMessageEditsStats()
{
if (m_currentMessageRequestId.isEmpty()) {
if (m_currentMessageTotalEdits != 0 || m_currentMessageAppliedEdits != 0 ||
m_currentMessagePendingEdits != 0 || m_currentMessageRejectedEdits != 0) {
m_currentMessageTotalEdits = 0;
m_currentMessageAppliedEdits = 0;
m_currentMessagePendingEdits = 0;
m_currentMessageRejectedEdits = 0;
emit currentMessageEditsStatsChanged();
}
return;
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
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_currentMessageTotalEdits != total) {
m_currentMessageTotalEdits = total;
changed = true;
}
if (m_currentMessageAppliedEdits != applied) {
m_currentMessageAppliedEdits = applied;
changed = true;
}
if (m_currentMessagePendingEdits != pending) {
m_currentMessagePendingEdits = pending;
changed = true;
}
if (m_currentMessageRejectedEdits != rejected) {
m_currentMessageRejectedEdits = 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 currentMessageEditsStatsChanged();
}
}
int ChatRootView::currentMessageTotalEdits() const
{
return m_currentMessageTotalEdits;
}
int ChatRootView::currentMessageAppliedEdits() const
{
return m_currentMessageAppliedEdits;
}
int ChatRootView::currentMessagePendingEdits() const
{
return m_currentMessagePendingEdits;
}
int ChatRootView::currentMessageRejectedEdits() const
{
return m_currentMessageRejectedEdits;
}
QString ChatRootView::lastInfoMessage() const
{
return m_lastInfoMessage;
}
} // namespace QodeAssist::Chat

View File

@ -38,6 +38,24 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL)
Q_PROPERTY(int inputTokensCount READ inputTokensCount NOTIFY inputTokensCountChanged FINAL)
Q_PROPERTY(QString chatFileName READ chatFileName NOTIFY chatFileNameChanged FINAL)
Q_PROPERTY(QString textFontFamily READ textFontFamily NOTIFY textFamilyChanged FINAL)
Q_PROPERTY(QString codeFontFamily READ codeFontFamily NOTIFY codeFamilyChanged FINAL)
Q_PROPERTY(int codeFontSize READ codeFontSize NOTIFY codeFontSizeChanged FINAL)
Q_PROPERTY(int textFontSize READ textFontSize NOTIFY textFontSizeChanged FINAL)
Q_PROPERTY(int textFormat READ textFormat NOTIFY textFormatChanged FINAL)
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 toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged 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)
QML_ELEMENT
@ -66,6 +84,7 @@ public:
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
Q_INVOKABLE void setIsSyncOpenFiles(bool state);
Q_INVOKABLE void openChatHistoryFolder();
Q_INVOKABLE void openRulesFolder();
Q_INVOKABLE void updateInputTokensCount();
int inputTokensCount() const;
@ -80,6 +99,44 @@ public:
void setRecentFilePath(const QString &filePath);
bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath);
QString textFontFamily() const;
QString codeFontFamily() const;
int codeFontSize() const;
int textFontSize() const;
int textFormat() const;
bool isRequestInProgress() const;
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 toolsSupportEnabled() const;
Q_INVOKABLE void applyFileEdit(const QString &editId);
Q_INVOKABLE void rejectFileEdit(const QString &editId);
Q_INVOKABLE void undoFileEdit(const QString &editId);
Q_INVOKABLE void openFileEditInEditor(const QString &editId);
// Mass file edit operations for current message
Q_INVOKABLE void applyAllFileEditsForCurrentMessage();
Q_INVOKABLE void undoAllFileEditsForCurrentMessage();
Q_INVOKABLE void updateCurrentMessageEditsStats();
int currentMessageTotalEdits() const;
int currentMessageAppliedEdits() const;
int currentMessagePendingEdits() const;
int currentMessageRejectedEdits() const;
QString lastInfoMessage() const;
public slots:
void sendMessage(const QString &message);
void copyToClipboard(const QString &text);
@ -95,8 +152,25 @@ signals:
void inputTokensCountChanged();
void isSyncOpenFilesChanged();
void chatFileNameChanged();
void textFamilyChanged();
void codeFamilyChanged();
void codeFontSizeChanged();
void textFontSizeChanged();
void textFormatChanged();
void chatRequestStarted();
void isRequestInProgressChanged();
void lastErrorMessageChanged();
void lastInfoMessageChanged();
void activeRulesChanged();
void activeRulesCountChanged();
void isAgentModeChanged();
void toolsSupportEnabledChanged();
void currentMessageEditsStatsChanged();
private:
void updateFileEditStatus(const QString &editId, const QString &status);
QString getChatsHistoryDir() const;
QString getSuggestedFileName() const;
@ -111,6 +185,17 @@ private:
int m_inputTokensCount{0};
bool m_isSyncOpenFiles;
QList<Core::IEditor *> m_currentEditors;
bool m_isRequestInProgress;
QString m_lastErrorMessage;
QVariantList m_activeRules;
bool m_isAgentMode;
QString m_currentMessageRequestId;
int m_currentMessageTotalEdits{0};
int m_currentMessageAppliedEdits{0};
int m_currentMessagePendingEdits{0};
int m_currentMessageRejectedEdits{0};
QString m_lastInfoMessage;
};
} // namespace QodeAssist::Chat

View File

@ -120,9 +120,14 @@ bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json)
}
model->clear();
model->setLoadingFromHistory(true);
for (const auto &message : messages) {
model->addMessage(message.content, message.role, message.id);
}
model->setLoadingFromHistory(false);
return true;
}

View File

@ -29,4 +29,40 @@ void ChatUtils::copyToClipboard(const QString &text)
QGuiApplication::clipboard()->setText(text);
}
QString ChatUtils::getSafeMarkdownText(const QString &text) const
{
if (text.isEmpty()) {
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());
for (QChar ch : text) {
if (ch.isNull()) {
safeText.append(' ');
} else if (ch == '\n' || ch == '\t' || ch == '\r' || ch == ' ') {
safeText.append(ch);
} else if (ch.isPrint()) {
safeText.append(ch);
} else {
safeText.append(QChar(0xFFFD));
}
}
return safeText;
}
} // namespace QodeAssist::Chat

View File

@ -34,6 +34,7 @@ public:
: QObject(parent) {};
Q_INVOKABLE void copyToClipboard(const QString &text);
Q_INVOKABLE QString getSafeMarkdownText(const QString &text) const;
};
} // namespace QodeAssist::Chat

106
ChatView/ChatView.cpp Normal file
View File

@ -0,0 +1,106 @@
/*
* 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 "ChatView.hpp"
#include <QQmlContext>
#include <QQmlEngine>
#include <QSettings>
#include <QVariantMap>
#include <coreplugin/actionmanager/actionmanager.h>
#include <logger/Logger.hpp>
namespace {
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
| Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint
| Qt::WindowCloseButtonHint;
}
namespace QodeAssist::Chat {
ChatView::ChatView()
: m_isPin(false)
{
setTitle("QodeAssist Chat");
engine()->rootContext()->setContextProperty("_chatview", this);
setSource(QUrl("qrc:/qt/qml/ChatView/qml/RootItem.qml"));
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());
}
});
}
restoreSettings();
}
void ChatView::closeEvent(QCloseEvent *event)
{
saveSettings();
event->accept();
}
void ChatView::saveSettings()
{
QSettings settings;
settings.setValue("QodeAssist/ChatView/geometry", geometry());
settings.setValue("QodeAssist/ChatView/pinned", m_isPin);
}
void ChatView::restoreSettings()
{
QSettings settings;
const QRect savedGeometry
= settings.value("QodeAssist/ChatView/geometry", QRect(100, 100, 800, 600)).toRect();
setGeometry(savedGeometry);
const bool pinned = settings.value("QodeAssist/ChatView/pinned", false).toBool();
setIsPin(pinned);
}
bool ChatView::isPin() const
{
return m_isPin;
}
void ChatView::setIsPin(bool newIsPin)
{
if (m_isPin == newIsPin)
return;
m_isPin = newIsPin;
if (m_isPin) {
setFlags(baseFlags | Qt::WindowStaysOnTopHint);
} else {
setFlags(baseFlags);
}
emit isPinChanged();
}
} // namespace QodeAssist::Chat

51
ChatView/ChatView.hpp Normal file
View File

@ -0,0 +1,51 @@
/*
* 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 <QQuickView>
#include <QShortcut>
namespace QodeAssist::Chat {
class ChatView : public QQuickView
{
Q_OBJECT
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
public:
ChatView();
bool isPin() const;
void setIsPin(bool newIsPin);
signals:
void isPinChanged();
protected:
void closeEvent(QCloseEvent *event) override;
private:
void saveSettings();
void restoreSettings();
bool m_isPin;
QShortcut *m_closeShortcut;
};
} // namespace QodeAssist::Chat

View File

@ -28,50 +28,47 @@
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/editormanager/ieditor.h>
#include <coreplugin/idocument.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectmanager.h>
#include <texteditor/textdocument.h>
#include <texteditor/texteditor.h>
#include "ChatAssistantSettings.hpp"
#include "GeneralSettings.hpp"
#include "ToolsSettings.hpp"
#include "Logger.hpp"
#include "ProvidersManager.hpp"
#include "RequestConfig.hpp"
#include <context/ChangesManager.h>
#include <RulesLoader.hpp>
namespace QodeAssist::Chat {
ClientInterface::ClientInterface(
ChatModel *chatModel, LLMCore::IPromptProvider *promptProvider, QObject *parent)
: QObject(parent)
, m_requestHandler(new LLMCore::RequestHandler(this))
, m_chatModel(chatModel)
, m_promptProvider(promptProvider)
, m_contextManager(new Context::ContextManager(this))
{
connect(
m_requestHandler,
&LLMCore::RequestHandler::completionReceived,
this,
[this](const QString &completion, const QJsonObject &request, bool isComplete) {
handleLLMResponse(completion, request, isComplete);
});
{}
connect(
m_requestHandler,
&LLMCore::RequestHandler::requestFinished,
this,
[this](const QString &, bool success, const QString &errorString) {
if (!success) {
emit errorOccurred(errorString);
}
});
}
ClientInterface::~ClientInterface() = default;
void ClientInterface::sendMessage(
const QString &message, const QList<QString> &attachments, const QList<QString> &linkedFiles)
ClientInterface::~ClientInterface()
{
cancelRequest();
}
void ClientInterface::sendMessage(
const QString &message,
const QList<QString> &attachments,
const QList<QString> &linkedFiles,
bool useAgentMode)
{
cancelRequest();
m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
auto attachFiles = m_contextManager->getContentFiles(attachments);
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles);
@ -96,8 +93,21 @@ void ClientInterface::sendMessage(
LLMCore::ContextData context;
const bool isToolsEnabled = Settings::toolsSettings().useTools() && useAgentMode;
if (chatAssistantSettings.useSystemPrompt()) {
QString systemPrompt = chatAssistantSettings.systemPrompt();
auto project = LLMCore::RulesLoader::getActiveProject();
if (project) {
QString projectRules
= LLMCore::RulesLoader::loadRulesForProject(project, LLMCore::RulesContext::Chat);
if (!projectRules.isEmpty()) {
systemPrompt += "\n# Project Rules\n\n" + projectRules;
}
}
if (!linkedFiles.isEmpty()) {
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
}
@ -106,6 +116,9 @@ void ClientInterface::sendMessage(
QVector<LLMCore::Message> messages;
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) {
continue;
}
messages.append({msg.role == ChatModel::ChatRole::User ? "user" : "assistant", msg.content});
}
context.history = messages;
@ -115,8 +128,7 @@ void ClientInterface::sendMessage(
config.provider = provider;
config.promptTemplate = promptTemplate;
if (provider->providerID() == LLMCore::ProviderID::GoogleAI) {
QString stream = chatAssistantSettings.stream() ? QString{"streamGenerateContent?alt=sse"}
: QString{"generateContent?"};
QString stream = QString{"streamGenerateContent?alt=sse"};
config.url = QUrl(QString("%1/models/%2:%3")
.arg(
Settings::generalSettings().caUrl(),
@ -126,17 +138,59 @@ void ClientInterface::sendMessage(
config.url
= QString("%1%2").arg(Settings::generalSettings().caUrl(), provider->chatEndpoint());
config.providerRequest
= {{"model", Settings::generalSettings().caModel()},
{"stream", chatAssistantSettings.stream()}};
= {{"model", Settings::generalSettings().caModel()}, {"stream", true}};
}
config.apiKey = provider->apiKey();
config.provider
->prepareRequest(config.providerRequest, promptTemplate, context, LLMCore::RequestType::Chat);
config.provider->prepareRequest(
config.providerRequest, promptTemplate, context, LLMCore::RequestType::Chat, isToolsEnabled);
QJsonObject request{{"id", QUuid::createUuid().toString()}};
m_requestHandler->sendLLMRequest(config, request);
QString requestId = QUuid::createUuid().toString();
QJsonObject request{{"id", requestId}};
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);
provider->sendRequest(requestId, config.url, config.providerRequest);
}
void ClientInterface::clearMessages()
@ -147,8 +201,28 @@ void ClientInterface::clearMessages()
void ClientInterface::cancelRequest()
{
auto id = m_chatModel->lastMessageId();
m_requestHandler->cancelRequest(id);
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());
}
}
m_activeRequests.clear();
m_accumulatedResponses.clear();
LOG_MESSAGE("All requests cancelled and state cleared");
}
void ClientInterface::handleLLMResponse(
@ -214,4 +288,60 @@ Context::ContextManager *ClientInterface::contextManager() const
return m_contextManager;
}
void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
m_accumulatedResponses[requestId] += partialText;
const RequestContext &ctx = it.value();
handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest, false);
}
void ClientInterface::handleFullResponse(const QString &requestId, const QString &fullText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
const RequestContext &ctx = it.value();
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));
}
handleLLMResponse(finalText, ctx.originalRequest, true);
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
}
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
{
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);
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
}
void ClientInterface::handleCleanAccumulatedData(const QString &requestId)
{
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
} // namespace QodeAssist::Chat

View File

@ -24,7 +24,7 @@
#include <QVector>
#include "ChatModel.hpp"
#include "RequestHandler.hpp"
#include "Provider.hpp"
#include "llmcore/IPromptProvider.hpp"
#include <context/ContextManager.hpp>
@ -42,7 +42,8 @@ public:
void sendMessage(
const QString &message,
const QList<QString> &attachments = {},
const QList<QString> &linkedFiles = {});
const QList<QString> &linkedFiles = {},
bool useAgentMode = false);
void clearMessages();
void cancelRequest();
@ -51,6 +52,13 @@ public:
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);
private:
void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete);
@ -58,10 +66,18 @@ private:
QString getSystemPromptWithLinkedFiles(
const QString &basePrompt, const QList<QString> &linkedFiles) const;
struct RequestContext
{
QJsonObject originalRequest;
LLMCore::Provider *provider;
};
LLMCore::IPromptProvider *m_promptProvider = nullptr;
ChatModel *m_chatModel;
LLMCore::RequestHandler *m_requestHandler;
Context::ContextManager *m_contextManager;
QHash<QString, RequestContext> m_activeRequests;
QHash<QString, QString> m_accumulatedResponses;
};
} // namespace QodeAssist::Chat

View File

@ -19,33 +19,24 @@
#pragma once
#include <qobject.h>
#include <qqmlintegration.h>
#include <QObject>
#include <QtQmlIntegration>
#include "ChatData.hpp"
namespace QodeAssist::Chat {
Q_NAMESPACE
class MessagePart
{
Q_GADGET
Q_PROPERTY(PartType type MEMBER type CONSTANT FINAL)
Q_PROPERTY(MessagePartType type MEMBER type CONSTANT FINAL)
Q_PROPERTY(QString text MEMBER text CONSTANT FINAL)
Q_PROPERTY(QString language MEMBER language CONSTANT FINAL)
QML_VALUE_TYPE(messagePart)
public:
enum PartType { Code, Text };
Q_ENUM(PartType)
PartType type;
MessagePartType type;
QString text;
QString language;
};
class MessagePartType : public MessagePart
{
Q_GADGET
};
QML_NAMED_ELEMENT(MessagePart)
QML_FOREIGN_NAMESPACE(QodeAssist::Chat::MessagePartType)
} // namespace QodeAssist::Chat

View File

@ -0,0 +1,15 @@
<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_61)">
<mask id="mask0_74_61" 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_61)">
<path d="M8 22L18 32L36 12" stroke="black" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_61">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5_6)">
<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"/>
</g>
<defs>
<clipPath id="clip0_5_6">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5_17)">
<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.8ZM8.4 6H15.6V13.2H8.4V6Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_5_17">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,8 @@
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.75 15H3.25C2.00736 15 1 16.0074 1 17.25V39.75C1 40.9926 2.00736 42 3.25 42H16.75C17.9926 42 19 40.9926 19 39.75V17.25C19 16.0074 17.9926 15 16.75 15Z" stroke="black" stroke-width="2"/>
<path d="M1.04316 11.015L18.9554 8.90787" stroke="black" stroke-width="2" stroke-linecap="round"/>
<path d="M7.19462 10.363L7.02032 8.59516C6.92446 7.62284 8.18688 6.64116 9.8257 6.41365C11.4645 6.18615 12.8838 6.79555 12.9797 7.76787L13.154 9.53573" stroke="black" stroke-width="2"/>
<path d="M6 24V34" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M10 24V34" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M14 24V34" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 822 B

View File

@ -0,0 +1,12 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_59_114)">
<path d="M2 8H12L16 4H40C42 4 44 6 44 8V36C44 38 42 40 40 40H6C4 40 2 38 2 36V8Z" fill="black" fill-opacity="0.1" stroke="black" stroke-width="3"/>
<path d="M25 37C32.732 37 39 30.732 39 23C39 15.268 32.732 9 25 9C17.268 9 11 15.268 11 23C11 30.732 17.268 37 25 37Z" stroke="black" stroke-width="4"/>
<path d="M33 35L42 44" stroke="black" stroke-width="4" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_59_114">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 624 B

View File

@ -0,0 +1,5 @@
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 8H15" stroke="black" stroke-width="2.5" stroke-linecap="round"/>
<path d="M10 16V36" stroke="black" stroke-width="2.5" stroke-linecap="round"/>
<path d="M5 21L10 16L15 21" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View 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

View File

@ -0,0 +1,16 @@
<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_76)">
<mask id="mask0_74_76" 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_76)">
<path d="M12 12L32 32" stroke="black" stroke-width="4" stroke-linecap="round"/>
<path d="M32 12L12 32" stroke="black" stroke-width="4" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_76">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@ -0,0 +1,9 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M35.75 2.75H8.25C6.73122 2.75 5.5 3.98122 5.5 5.5V38.5C5.5 40.0188 6.73122 41.25 8.25 41.25H35.75C37.2688 41.25 38.5 40.0188 38.5 38.5V5.5C38.5 3.98122 37.2688 2.75 35.75 2.75Z" stroke="black" stroke-width="4"/>
<path d="M13.75 14.4375C14.8891 14.4375 15.8125 13.5141 15.8125 12.375C15.8125 11.2359 14.8891 10.3125 13.75 10.3125C12.6109 10.3125 11.6875 11.2359 11.6875 12.375C11.6875 13.5141 12.6109 14.4375 13.75 14.4375Z" fill="black"/>
<path d="M19.25 12.375H33" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M13.75 24.0625C14.8891 24.0625 15.8125 23.1391 15.8125 22C15.8125 20.8609 14.8891 19.9375 13.75 19.9375C12.6109 19.9375 11.6875 20.8609 11.6875 22C11.6875 23.1391 12.6109 24.0625 13.75 24.0625Z" fill="black"/>
<path d="M19.25 22H33" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
<path d="M13.75 33.6875C14.8891 33.6875 15.8125 32.7641 15.8125 31.625C15.8125 30.4859 14.8891 29.5625 13.75 29.5625C12.6109 29.5625 11.6875 30.4859 11.6875 31.625C11.6875 32.7641 12.6109 33.6875 13.75 33.6875Z" fill="black"/>
<path d="M19.25 31.625H27.5" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8V28" stroke="black" stroke-width="2.5" stroke-linecap="round"/>
<path d="M5 23L10 28L15 23" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 36H15" stroke="black" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1,16 @@
<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_68)">
<mask id="mask0_74_68" 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_68)">
<path d="M12 12L6 18L12 24" stroke="black" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 18H28C33 18 38 23 38 28C38 33 33 38 28 38H22" stroke="black" stroke-width="4" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_74_68">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@ -0,0 +1,5 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31 18H13C11.3431 18 10 19.3431 10 21V37C10 38.6569 11.3431 40 13 40H31C32.6569 40 34 38.6569 34 37V21C34 19.3431 32.6569 18 31 18Z" fill="black" fill-opacity="0.3" stroke="black" stroke-width="4"/>
<path d="M14 18V10C14 5.6 17.6 2 22 2C26.4 2 30 5.6 30 10V18" stroke="black" stroke-width="4"/>
<path d="M22 32C23.6569 32 25 30.6569 25 29C25 27.3431 23.6569 26 22 26C20.3431 26 19 27.3431 19 29C19 30.6569 20.3431 32 22 32Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@ -0,0 +1,5 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31 18H13C11.3431 18 10 19.3431 10 21V37C10 38.6569 11.3431 40 13 40H31C32.6569 40 34 38.6569 34 37V21C34 19.3431 32.6569 18 31 18Z" fill="black" fill-opacity="0.1" stroke="black" stroke-width="4"/>
<path d="M14 17V9.5C14 5.375 17.15 2 21 2C24.85 2 27.5 2.875 27.5 7" stroke="black" stroke-width="4"/>
<path d="M22 32C23.6569 32 25 30.6569 25 29C25 27.3431 23.6569 26 22 26C20.3431 26 19 27.3431 19 29C19 30.6569 20.3431 32 22 32Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@ -20,6 +20,8 @@
import QtQuick
import ChatView
import QtQuick.Layouts
import UIControls
import "./dialog"
Rectangle {
@ -27,6 +29,23 @@ Rectangle {
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
@ -67,8 +86,8 @@ Rectangle {
}
switch(modelData.type) {
case MessagePart.Text: return textComponent;
case MessagePart.Code: return codeBlockComponent;
case MessagePartType.Text: return textComponent;
case MessagePartType.Code: return codeBlockComponent;
default: return textComponent;
}
}
@ -140,8 +159,7 @@ Rectangle {
anchors {
right: parent.right
bottom: parent.bottom
bottomMargin: 2
top: parent.top
}
text: qsTr("ResetTo")
@ -156,11 +174,28 @@ Rectangle {
height: implicitHeight + 10
verticalAlignment: Text.AlignVCenter
leftPadding: 10
text: itemData.text
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
@ -171,5 +206,7 @@ Rectangle {
code: itemData.text
language: itemData.language
codeFontFamily: root.codeFontFamily
codeFontSize: root.codeFontSize
}
}

View File

@ -0,0 +1,423 @@
/*
* 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 UIControls
Rectangle {
id: root
property string editContent: ""
readonly property var editData: parseEditData(editContent)
readonly property string filePath: editData.file || ""
readonly property string fileName: getFileName(filePath)
readonly property string editStatus: editData.status || "pending"
readonly property string statusMessage: editData.status_message || ""
readonly property string oldContent: editData.old_content || ""
readonly property string newContent: editData.new_content || ""
signal applyEdit(string editId)
signal rejectEdit(string editId)
signal undoEdit(string editId)
signal openInEditor(string editId)
readonly property int borderRadius: 4
readonly property int contentMargin: 10
readonly property int contentBottomPadding: 20
readonly property int headerPadding: 8
readonly property int statusIndicatorWidth: 4
readonly property bool isPending: editStatus === "pending"
readonly property bool isApplied: editStatus === "applied"
readonly property bool isRejected: editStatus === "rejected"
readonly property bool isArchived: editStatus === "archived"
readonly property color appliedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
readonly property color revertedColor: Qt.rgba(0.8, 0.6, 0.2, 0.8)
readonly property color rejectedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8)
readonly property color archivedColor: Qt.rgba(0.5, 0.5, 0.5, 0.8)
readonly property color pendingColor: palette.highlight
readonly property color appliedBgColor: Qt.rgba(0.2, 0.8, 0.2, 0.3)
readonly property color revertedBgColor: Qt.rgba(0.8, 0.6, 0.2, 0.3)
readonly property color rejectedBgColor: Qt.rgba(0.8, 0.2, 0.2, 0.3)
readonly property color archivedBgColor: Qt.rgba(0.5, 0.5, 0.5, 0.3)
readonly property string codeFontFamily: {
switch (Qt.platform.os) {
case "windows": return "Consolas"
case "osx": return "Menlo"
case "linux": return "DejaVu Sans Mono"
default: return "monospace"
}
}
readonly property int codeFontSize: Qt.application.font.pointSize
readonly property color statusColor: {
if (isArchived) return archivedColor
if (isApplied) return appliedColor
if (isRejected) return rejectedColor
return pendingColor
}
readonly property color statusBgColor: {
if (isArchived) return archivedBgColor
if (isApplied) return appliedBgColor
if (isRejected) return rejectedBgColor
return palette.button
}
readonly property string statusText: {
if (isArchived) return qsTr("ARCHIVED")
if (isApplied) return qsTr("APPLIED")
if (isRejected) return qsTr("REJECTED")
return qsTr("PENDING")
}
readonly property int addedLines: countLines(newContent)
readonly property int removedLines: countLines(oldContent)
function parseEditData(content) {
try {
const marker = "QODEASSIST_FILE_EDIT:";
let jsonStr = content;
if (content.indexOf(marker) >= 0) {
jsonStr = content.substring(content.indexOf(marker) + marker.length);
}
return JSON.parse(jsonStr);
} catch (e) {
return {
edit_id: "",
file: "",
old_content: "",
new_content: "",
status: "error",
status_message: ""
};
}
}
function getFileName(path) {
if (!path) return "";
const parts = path.split('/');
return parts[parts.length - 1];
}
function countLines(text) {
if (!text) return 0;
return text.split('\n').length;
}
implicitHeight: fileEditView.implicitHeight
Rectangle {
id: fileEditView
property bool expanded: false
anchors.fill: parent
implicitHeight: expanded ? headerArea.height + contentColumn.implicitHeight + root.contentBottomPadding + root.contentMargin * 2
: headerArea.height
radius: root.borderRadius
color: palette.base
border.width: 1
border.color: root.isPending
? (color.hslLightness > 0.5 ? Qt.darker(color, 1.3) : Qt.lighter(color, 1.3))
: Qt.alpha(root.statusColor, 0.6)
clip: true
Behavior on implicitHeight {
NumberAnimation {
duration: 200
easing.type: Easing.InOutQuad
}
}
states: [
State {
name: "expanded"
when: fileEditView.expanded
PropertyChanges { target: contentColumn; opacity: 1 }
},
State {
name: "collapsed"
when: !fileEditView.expanded
PropertyChanges { target: contentColumn; opacity: 0 }
}
]
transitions: Transition {
NumberAnimation {
properties: "opacity"
duration: 200
easing.type: Easing.InOutQuad
}
}
MouseArea {
id: headerArea
width: parent.width
height: headerRow.height + 16
cursorShape: Qt.PointingHandCursor
onClicked: fileEditView.expanded = !fileEditView.expanded
RowLayout {
id: headerRow
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
right: actionButtons.left
leftMargin: root.contentMargin
rightMargin: root.contentMargin
}
spacing: root.headerPadding
Rectangle {
width: root.statusIndicatorWidth
height: headerText.height
radius: 2
color: root.statusColor
}
Text {
id: headerText
Layout.fillWidth: true
text: {
var modeText = root.oldContent.length > 0 ? qsTr("Replace") : qsTr("Append")
if (root.oldContent.length > 0) {
return qsTr("%1: %2 (+%3 -%4)")
.arg(modeText)
.arg(root.fileName)
.arg(root.addedLines)
.arg(root.removedLines)
} else {
return qsTr("%1: %2 (+%3)")
.arg(modeText)
.arg(root.fileName)
.arg(root.addedLines)
}
}
font.pixelSize: 12
font.bold: true
color: palette.text
elide: Text.ElideMiddle
}
Text {
text: fileEditView.expanded ? "▼" : "▶"
font.pixelSize: 10
color: palette.mid
}
Rectangle {
visible: !root.isPending
Layout.preferredWidth: badgeText.width + 12
Layout.preferredHeight: badgeText.height + 4
color: root.statusBgColor
radius: 3
Text {
id: badgeText
anchors.centerIn: parent
text: root.statusText
font.pixelSize: 9
font.bold: true
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 {
source: "qrc:/qt/qml/ChatView/icons/apply-changes-button.svg"
height: 15
width: 15
} enabled: (root.isPending || root.isRejected) && !root.isArchived
visible: !root.isApplied && !root.isArchived
onClicked: root.applyEdit(editData.edit_id)
}
QoAButton {
icon {
source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg"
height: 15
width: 15
}
enabled: root.isApplied && !root.isArchived
visible: root.isApplied && !root.isArchived
onClicked: root.undoEdit(editData.edit_id)
}
QoAButton {
icon {
source: "qrc:/qt/qml/ChatView/icons/reject-changes-button.svg"
height: 15
width: 15
}
enabled: root.isPending && !root.isArchived
visible: root.isPending && !root.isArchived
onClicked: root.rejectEdit(editData.edit_id)
}
}
}
ColumnLayout {
id: contentColumn
anchors {
left: parent.left
right: parent.right
top: headerArea.bottom
margins: root.contentMargin
}
spacing: 8
visible: opacity > 0
Text {
Layout.fillWidth: true
text: root.filePath
font.pixelSize: 10
color: palette.mid
elide: Text.ElideMiddle
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: oldContentColumn.implicitHeight + 12
color: Qt.rgba(1, 0.2, 0.2, 0.1)
radius: 4
border.width: 1
border.color: Qt.rgba(1, 0.2, 0.2, 0.3)
visible: root.oldContent.length > 0
Column {
id: oldContentColumn
width: parent.width
x: 6
y: 6
spacing: 4
Text {
text: qsTr("- Removed:")
font.pixelSize: 10
font.bold: true
color: Qt.rgba(1, 0.2, 0.2, 0.9)
}
TextEdit {
id: oldContentText
width: parent.width - 12
height: contentHeight
text: root.oldContent
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: palette.text
wrapMode: TextEdit.Wrap
readOnly: true
selectByMouse: true
selectByKeyboard: true
textFormat: TextEdit.PlainText
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: newContentColumn.implicitHeight + 12
color: Qt.rgba(0.2, 0.8, 0.2, 0.1)
radius: 4
border.width: 1
border.color: Qt.rgba(0.2, 0.8, 0.2, 0.3)
Column {
id: newContentColumn
width: parent.width
x: 6
y: 6
spacing: 4
Text {
text: qsTr("+ Added:")
font.pixelSize: 10
font.bold: true
color: Qt.rgba(0.2, 0.8, 0.2, 0.9)
}
TextEdit {
id: newContentText
width: parent.width - 12
height: contentHeight
text: root.newContent
font.family: root.codeFontFamily
font.pixelSize: root.codeFontSize
color: palette.text
wrapMode: TextEdit.Wrap
readOnly: true
selectByMouse: true
selectByKeyboard: true
textFormat: TextEdit.PlainText
}
}
}
Text {
Layout.fillWidth: true
visible: root.statusMessage.length > 0
text: root.statusMessage
font.pixelSize: 10
font.italic: true
color: root.isApplied
? Qt.rgba(0.2, 0.6, 0.2, 1)
: Qt.rgba(0.8, 0.2, 0.2, 1)
wrapMode: Text.WordWrap
}
}
}
}

View File

@ -22,7 +22,8 @@ import QtQuick.Controls
import QtQuick.Controls.Basic as QQC
import QtQuick.Layouts
import ChatView
import "./controls"
import UIControls
import Qt.labs.platform as Platform
import "./parts"
ChatRootView {
@ -64,18 +65,32 @@ ChatRootView {
id: topBar
Layout.preferredWidth: parent.width
Layout.preferredHeight: 40
Layout.preferredHeight: childrenRect.height + 10
saveButton.onClicked: root.showSaveDialog()
loadButton.onClicked: root.showLoadDialog()
clearButton.onClicked: root.clearChat()
tokensBadge {
text: qsTr("tokens:%1/%2").arg(root.inputTokensCount).arg(root.chatModel.tokensThreshold)
text: qsTr("%1/%2").arg(root.inputTokensCount).arg(root.chatModel.tokensThreshold)
}
recentPath {
text: qsTr("Latest chat file name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
}
openChatHistory.onClicked: root.openChatHistoryFolder()
rulesButton.onClicked: rulesViewer.open()
activeRulesCount: root.activeRulesCount
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
}
}
}
ListView {
@ -90,20 +105,20 @@ ChatRootView {
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 2000
delegate: ChatItem {
delegate: Loader {
required property var model
required property int index
width: ListView.view.width - scroll.width
msgModel: root.chatModel.processMessageContent(model.content)
messageAttachments: model.attachments
isUserMessage: model.roleType === ChatModel.User
messageIndex: index
onResetChatToMessage: function(index) {
messageInput.text = model.content
messageInput.cursorPosition = model.content.length
root.chatModel.resetModelTo(index)
sourceComponent: {
if (model.roleType === ChatModel.Tool) {
return toolMessageComponent
} else if (model.roleType === ChatModel.FileEdit) {
return fileEditMessageComponent
} else {
return chatItemComponent
}
}
}
@ -125,6 +140,65 @@ ChatRootView {
root.scrollToBottom()
}
}
Component {
id: chatItemComponent
ChatItem {
id: chatItemInstance
width: parent.width
msgModel: root.chatModel.processMessageContent(model.content)
messageAttachments: model.attachments
isUserMessage: model.roleType === ChatModel.User
messageIndex: index
textFontFamily: root.textFontFamily
codeFontFamily: root.codeFontFamily
codeFontSize: root.codeFontSize
textFontSize: root.textFontSize
textFormat: root.textFormat
onResetChatToMessage: function(idx) {
messageInput.text = model.content
messageInput.cursorPosition = model.content.length
root.chatModel.resetModelTo(idx)
}
}
}
Component {
id: toolMessageComponent
ToolStatusItem {
width: parent.width
toolContent: model.content
}
}
Component {
id: fileEditMessageComponent
FileEditItem {
width: parent.width
editContent: model.content
onApplyEdit: function(editId) {
root.applyFileEdit(editId)
}
onRejectEdit: function(editId) {
root.rejectFileEdit(editId)
}
onUndoEdit: function(editId) {
root.undoFileEdit(editId)
}
onOpenInEditor: function(editId) {
root.openFileEditInEditor(editId)
}
}
}
}
ScrollView {
@ -137,7 +211,9 @@ ChatRootView {
QQC.TextArea {
id: messageInput
placeholderText: qsTr("Type your message here...")
placeholderText: Qt.platform.os === "osx"
? qsTr("Type your message here... (⌘+↩ to send)")
: qsTr("Type your message here... (Ctrl+Enter to send)")
placeholderTextColor: palette.mid
color: palette.text
background: Rectangle {
@ -160,15 +236,53 @@ ChatRootView {
onTextChanged: root.calculateMessageTokensCount(messageInput.text)
Keys.onPressed: function(event) {
if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && !(event.modifiers & Qt.ShiftModifier)) {
root.sendChatMessage()
event.accepted = true;
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: messageContextMenu.open()
propagateComposedEvents: true
}
}
}
Platform.Menu {
id: messageContextMenu
Platform.MenuItem {
text: qsTr("Cut")
enabled: messageInput.selectedText.length > 0
onTriggered: messageInput.cut()
}
Platform.MenuItem {
text: qsTr("Copy")
enabled: messageInput.selectedText.length > 0
onTriggered: messageInput.copy()
}
Platform.MenuItem {
text: qsTr("Paste")
enabled: messageInput.canPaste
onTriggered: messageInput.paste()
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("Select All")
enabled: messageInput.text.length > 0
onTriggered: messageInput.selectAll()
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("Clear")
enabled: messageInput.text.length > 0
onTriggered: messageInput.clear()
}
}
AttachedFilesPlace {
id: attachedFilesPlace
@ -191,14 +305,31 @@ ChatRootView {
onRemoveFileFromListByIndex: (index) => root.removeFileFromLinkList(index)
}
FileEditsActionBar {
id: fileEditsActionBar
Layout.fillWidth: true
totalEdits: root.currentMessageTotalEdits
appliedEdits: root.currentMessageAppliedEdits
pendingEdits: root.currentMessagePendingEdits
rejectedEdits: root.currentMessageRejectedEdits
onApplyAllClicked: root.applyAllFileEditsForCurrentMessage()
onUndoAllClicked: root.undoAllFileEditsForCurrentMessage()
}
BottomBar {
id: bottomBar
Layout.preferredWidth: parent.width
Layout.preferredHeight: 40
sendButton.onClicked: root.sendChatMessage()
stopButton.onClicked: root.cancelRequest()
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")
syncOpenFiles {
checked: root.isSyncOpenFiles
onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
@ -208,6 +339,18 @@ ChatRootView {
}
}
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.clearAttachmentFiles()
@ -223,4 +366,55 @@ ChatRootView {
messageInput.text = ""
scrollToBottom()
}
Toast {
id: errorToast
z: 1000
color: Qt.rgba(0.8, 0.2, 0.2, 0.7)
border.color: Qt.darker(infoToast.color, 1.3)
toastTextColor: "#FFFFFF"
}
Toast {
id: infoToast
z: 1000
color: Qt.rgba(0.2, 0.8, 0.2, 0.7)
border.color: Qt.darker(infoToast.color, 1.3)
toastTextColor: "#FFFFFF"
}
RulesViewer {
id: rulesViewer
width: parent.width * 0.8
height: parent.height * 0.8
x: (parent.width - width) / 2
y: (parent.height - height) / 2
activeRules: root.activeRules
ruleContentAreaText: root.getRuleContent(rulesViewer.rulesCurrentIndex)
onRefreshRules: root.refreshRules()
onOpenRulesFolder: root.openRulesFolder()
}
Connections {
target: root
function onLastErrorMessageChanged() {
if (root.lastErrorMessage.length > 0) {
errorToast.show(root.lastErrorMessage)
}
}
function onLastInfoMessageChanged() {
if (root.lastInfoMessage.length > 0) {
infoToast.show(root.lastInfoMessage)
}
}
}
Component.onCompleted: {
messageInput.forceActiveFocus()
}
}

View File

@ -0,0 +1,160 @@
/*
* 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 Qt.labs.platform as Platform
Rectangle {
id: root
property string toolContent: ""
property bool expanded: false
readonly property int firstNewline: toolContent.indexOf('\n')
readonly property string toolName: firstNewline > 0 ? toolContent.substring(0, firstNewline) : toolContent
readonly property string toolResult: firstNewline > 0 ? toolContent.substring(firstNewline + 1) : ""
radius: 6
color: palette.base
clip: true
Behavior on implicitHeight {
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
MouseArea {
id: header
width: parent.width
height: headerRow.height + 10
cursorShape: Qt.PointingHandCursor
onClicked: root.expanded = !root.expanded
Row {
id: headerRow
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
leftMargin: 10
}
width: parent.width
spacing: 8
Text {
text: qsTr("Tool: %1").arg(root.toolName)
font.pixelSize: 13
font.bold: true
color: palette.text
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▼" : "▶"
font.pixelSize: 10
color: palette.mid
}
}
}
Column {
id: contentColumn
anchors {
left: parent.left
right: parent.right
top: header.bottom
margins: 10
}
spacing: 8
TextEdit {
id: resultText
width: parent.width
text: root.toolResult
readOnly: true
selectByMouse: true
color: palette.text
wrapMode: Text.WordWrap
font.family: "monospace"
font.pixelSize: 11
selectionColor: palette.highlight
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: contextMenu.open()
propagateComposedEvents: true
}
Platform.Menu {
id: contextMenu
Platform.MenuItem {
text: qsTr("Copy")
enabled: resultText.selectedText.length > 0
onTriggered: resultText.copy()
}
Platform.MenuItem {
text: qsTr("Select All")
enabled: resultText.text.length > 0
onTriggered: resultText.selectAll()
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: root.expanded ? qsTr("Collapse") : qsTr("Expand")
onTriggered: root.expanded = !root.expanded
}
}
Rectangle {
id: messageMarker
anchors.verticalCenter: parent.verticalCenter
width: 3
height: root.height - root.radius
color: root.color.hslLightness > 0.5 ? Qt.darker(palette.alternateBase, 1.3)
: Qt.lighter(palette.alternateBase, 1.3)
radius: root.radius
}
states: [
State {
when: !root.expanded
PropertyChanges {
target: root
implicitHeight: header.height
}
},
State {
when: root.expanded
PropertyChanges {
target: root
implicitHeight: header.height + contentColumn.height + 20
}
}
]
}

View File

@ -20,71 +20,127 @@
import QtQuick
import QtQuick.Controls
import ChatView
import UIControls
import Qt.labs.platform as Platform
Rectangle {
id: root
property string code: ""
property string language: ""
property bool expanded: false
readonly property string monospaceFont: {
switch (Qt.platform.os) {
case "windows":
return "Consolas";
case "osx":
return "Menlo";
case "linux":
return "DejaVu Sans Mono";
default:
return "monospace";
}
}
property alias codeFontFamily: codeText.font.family
property alias codeFontSize: codeText.font.pointSize
readonly property real collapsedHeight: copyButton.height + 10
color: palette.alternateBase
border.color: root.color.hslLightness > 0.5 ? Qt.darker(root.color, 1.3)
: Qt.lighter(root.color, 1.3)
border.width: 2
radius: 4
implicitWidth: parent.width
implicitHeight: codeText.implicitHeight + 20
clip: true
Behavior on implicitHeight {
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
ChatUtils {
id: utils
}
HoverHandler {
id: hoverHandler
enabled: true
}
MouseArea {
id: header
width: parent.width
height: root.collapsedHeight
cursorShape: Qt.PointingHandCursor
onClicked: root.expanded = !root.expanded
Row {
id: headerRow
anchors {
verticalCenter: parent.verticalCenter
left: parent.left
leftMargin: 10
}
spacing: 6
Text {
text: root.language ? qsTr("Code (%1)").arg(root.language) :
qsTr("Code")
font.pixelSize: 12
font.bold: true
color: palette.text
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "▼" : "▶"
font.pixelSize: 10
color: palette.mid
}
}
}
TextEdit {
id: codeText
anchors.fill: parent
anchors.margins: 10
anchors {
left: parent.left
right: parent.right
top: header.bottom
margins: 10
}
text: root.code
readOnly: true
selectByMouse: true
font.family: root.monospaceFont
font.pointSize: Qt.application.font.pointSize
color: parent.color.hslLightness > 0.5 ? "black" : "white"
wrapMode: Text.WordWrap
selectionColor: palette.highlight
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: contextMenu.open()
}
}
TextEdit {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 5
readOnly: true
selectByMouse: true
text: root.language
color: root.color.hslLightness > 0.5 ? Qt.darker(root.color, 1.1)
: Qt.lighter(root.color, 1.1)
font.pointSize: 8
Platform.Menu {
id: contextMenu
Platform.MenuItem {
text: qsTr("Copy")
onTriggered: {
const textToCopy = codeText.selectedText || root.code
utils.copyToClipboard(textToCopy)
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: root.expanded ? qsTr("Collapse") : qsTr("Expand")
onTriggered: root.expanded = !root.expanded
}
}
QoAButton {
anchors.top: parent.top
id: copyButton
anchors.right: parent.right
anchors.margins: 5
text: "Copy"
anchors.rightMargin: 5
y: 5
text: qsTr("Copy")
onClicked: {
utils.copyToClipboard(root.code)
text = qsTr("Copied")
@ -97,4 +153,21 @@ Rectangle {
onTriggered: parent.text = qsTr("Copy")
}
}
states: [
State {
when: !root.expanded
PropertyChanges {
target: root
implicitHeight: root.collapsedHeight
}
},
State {
when: root.expanded
PropertyChanges {
target: root
implicitHeight: header.height + codeText.implicitHeight + 10
}
}
]
}

View File

@ -18,6 +18,7 @@
*/
import QtQuick
import Qt.labs.platform as Platform
TextEdit {
id: root
@ -25,7 +26,29 @@ TextEdit {
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
textFormat: Text.StyledText
selectionColor: palette.highlight
color: palette.text
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: contextMenu.open()
propagateComposedEvents: true
}
Platform.Menu {
id: contextMenu
Platform.MenuItem {
text: qsTr("Copy")
enabled: root.selectedText.length > 0
onTriggered: root.copy()
}
Platform.MenuItem {
text: qsTr("Select All")
enabled: root.text.length > 0
onTriggered: root.selectAll()
}
}
}

View File

@ -21,16 +21,17 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ChatView
import UIControls
Rectangle {
id: root
property alias sendButton: sendButtonId
property alias stopButton: stopButtonId
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)
@ -51,13 +52,12 @@ Rectangle {
QoAButton {
id: sendButtonId
text: qsTr("Send")
}
QoAButton {
id: stopButtonId
text: qsTr("Stop")
icon {
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
}
QoAButton {
@ -68,7 +68,9 @@ Rectangle {
height: 15
width: 8
}
text: qsTr("Attach files")
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Attach file to message")
}
QoAButton {
@ -79,7 +81,9 @@ Rectangle {
height: 15
width: 8
}
text: qsTr("Link files")
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Link file to context")
}
CheckBox {

View File

@ -0,0 +1,161 @@
/*
* 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 UIControls
Rectangle {
id: root
property int totalEdits: 0
property int appliedEdits: 0
property int pendingEdits: 0
property int rejectedEdits: 0
property bool hasAppliedEdits: appliedEdits > 0
property bool hasRejectedEdits: rejectedEdits > 0
property bool hasPendingEdits: pendingEdits > 0
signal applyAllClicked()
signal undoAllClicked()
visible: totalEdits > 0
implicitHeight: visible ? 40 : 0
color: palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.05) :
Qt.lighter(palette.window, 1.05)
border.width: 1
border.color: palette.mid
Behavior on implicitHeight {
NumberAnimation {
duration: 200
easing.type: Easing.InOutQuad
}
}
RowLayout {
anchors {
left: parent.left
leftMargin: 10
right: parent.right
rightMargin: 10
verticalCenter: parent.verticalCenter
}
spacing: 10
Rectangle {
Layout.preferredWidth: 24
Layout.preferredHeight: 24
radius: 12
color: {
if (root.hasPendingEdits) return Qt.rgba(0.2, 0.6, 1.0, 0.2)
if (root.hasAppliedEdits) return Qt.rgba(0.2, 0.8, 0.2, 0.2)
return Qt.rgba(0.8, 0.6, 0.2, 0.2)
}
border.width: 2
border.color: {
if (root.hasPendingEdits) return Qt.rgba(0.2, 0.6, 1.0, 0.8)
if (root.hasAppliedEdits) return Qt.rgba(0.2, 0.8, 0.2, 0.8)
return Qt.rgba(0.8, 0.6, 0.2, 0.8)
}
Text {
anchors.centerIn: parent
text: root.totalEdits
font.pixelSize: 10
font.bold: true
color: palette.text
}
}
// Status text
ColumnLayout {
spacing: 2
Text {
text: root.totalEdits === 1
? qsTr("File Edit in Current Message")
: qsTr("%1 File Edits in Current Message").arg(root.totalEdits)
font.pixelSize: 11
font.bold: true
color: palette.text
}
Text {
visible: root.totalEdits > 0
text: {
let parts = [];
if (root.appliedEdits > 0) {
parts.push(qsTr("%1 applied").arg(root.appliedEdits));
}
if (root.pendingEdits > 0) {
parts.push(qsTr("%1 pending").arg(root.pendingEdits));
}
if (root.rejectedEdits > 0) {
parts.push(qsTr("%1 rejected").arg(root.rejectedEdits));
}
return parts.join(", ");
}
font.pixelSize: 9
color: palette.mid
}
}
Item {
Layout.fillWidth: true
}
QoAButton {
id: applyAllButton
visible: root.hasPendingEdits || root.hasRejectedEdits
enabled: root.hasPendingEdits || root.hasRejectedEdits
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")
onClicked: root.applyAllClicked()
}
QoAButton {
id: undoAllButton
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")
onClicked: root.undoAllClicked()
}
}
}

View File

@ -0,0 +1,259 @@
/*
* 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
QQC.TextArea {
id: ruleContentArea
readOnly: true
wrapMode: TextArea.Wrap
selectByMouse: true
color: palette.text
font.family: "monospace"
font.pixelSize: 11
background: Rectangle {
color: Qt.darker(palette.base, 1.02)
border.color: palette.mid
border.width: 1
radius: 2
}
placeholderText: qsTr("Select a rule file to view its content")
}
}
}
}
}
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
}
}
}

View File

@ -0,0 +1,103 @@
/*
* 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
Rectangle {
id: root
property alias toastTextItem: textItem
property alias toastTextColor: textItem.color
property string errorText: ""
property int displayDuration: 5000
width: Math.min(parent.width - 40, textItem.implicitWidth + radius)
height: visible ? (textItem.implicitHeight + 12) : 0
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 10
color: "#d32f2f"
radius: height / 2
border.color: "#b71c1c"
border.width: 1
visible: false
opacity: 0
TextEdit {
id: textItem
anchors.centerIn: parent
anchors.margins: 6
text: root.errorText
color: palette.text
font.pixelSize: 13
wrapMode: TextEdit.Wrap
width: Math.min(implicitWidth, root.parent.width - 60)
horizontalAlignment: TextEdit.AlignHCenter
readOnly: true
selectByMouse: true
selectByKeyboard: true
selectionColor: "#b71c1c"
}
function show(message) {
errorText = message
visible = true
showAnimation.start()
hideTimer.restart()
}
function hide() {
hideAnimation.start()
}
NumberAnimation {
id: showAnimation
target: root
property: "opacity"
from: 0
to: 1
duration: 200
easing.type: Easing.OutQuad
}
NumberAnimation {
id: hideAnimation
target: root
property: "opacity"
from: 1
to: 0
duration: 200
easing.type: Easing.InQuad
onFinished: root.visible = false
}
Timer {
id: hideTimer
interval: root.displayDuration
running: false
repeat: false
onTriggered: root.hide()
}
}

View File

@ -19,7 +19,9 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import ChatView
import UIControls
Rectangle {
id: root
@ -30,59 +32,180 @@ Rectangle {
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 activeRulesCount: activeRulesCountId.text
color: palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.1) :
Qt.lighter(palette.window, 1.1)
RowLayout {
Flow {
anchors {
left: parent.left
leftMargin: 5
right: parent.right
rightMargin: 5
verticalCenter: parent.verticalCenter
margins: 5
}
spacing: 10
QoAButton {
id: saveButtonId
id: pinButtonId
text: qsTr("Save")
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")
}
QoAButton {
id: loadButtonId
QoATextSlider {
id: agentModeSwitchId
text: qsTr("Load")
}
leftText: "chat"
rightText: "AI Agent"
QoAButton {
id: clearButtonId
text: qsTr("Clear")
}
Text {
id: recentPathId
elide: Text.ElideMiddle
color: palette.text
}
QoAButton {
id: openChatHistoryId
text: qsTr("Show in system")
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")
}
}
Item {
Layout.fillWidth: true
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
}
}
}
Badge {
id: tokensBadgeId
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")
}
}
}
}

View File

@ -99,6 +99,19 @@ const QVector<LanguageProperties> &getKnownLanguages()
return knownLanguages;
}
bool CodeHandler::hasCodeBlocks(const QString &text)
{
QStringList lines = text.split('\n');
for (const QString &line : lines) {
if (line.trimmed().startsWith("```")) {
return true;
}
}
return false;
}
static QHash<QString, QString> buildLanguageToCommentPrefixMap()
{
QHash<QString, QString> result;

View File

@ -40,6 +40,11 @@ public:
*/
static QString detectLanguageFromExtension(const QString &extension);
/**
* Detects if text contains code blocks, or returns false if this was not possible
*/
static bool hasCodeBlocks(const QString &text);
private:
static QString getCommentPrefix(const QString &language);

View File

@ -26,12 +26,11 @@
#include "CodeHandler.hpp"
#include "context/DocumentContextReader.hpp"
#include "context/Utils.hpp"
#include "llmcore/PromptTemplateManager.hpp"
#include "llmcore/ProvidersManager.hpp"
#include "logger/Logger.hpp"
#include "settings/CodeCompletionSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include <llmcore/RequestConfig.hpp>
#include <llmcore/RulesLoader.hpp>
namespace QodeAssist {
@ -40,23 +39,21 @@ LLMClientInterface::LLMClientInterface(
const Settings::CodeCompletionSettings &completeSettings,
LLMCore::IProviderRegistry &providerRegistry,
LLMCore::IPromptProvider *promptProvider,
LLMCore::RequestHandlerBase &requestHandler,
Context::IDocumentReader &documentReader,
IRequestPerformanceLogger &performanceLogger)
: m_generalSettings(generalSettings)
, m_completeSettings(completeSettings)
, m_providerRegistry(providerRegistry)
, m_promptProvider(promptProvider)
, m_requestHandler(requestHandler)
, m_documentReader(documentReader)
, m_performanceLogger(performanceLogger)
, m_contextManager(new Context::ContextManager(this))
{
connect(
&m_requestHandler,
&LLMCore::RequestHandler::completionReceived,
this,
&LLMClientInterface::sendCompletionToClient);
}
LLMClientInterface::~LLMClientInterface()
{
handleCancelRequest();
}
Utils::FilePath LLMClientInterface::serverDeviceTemplate() const
@ -69,6 +66,29 @@ void LLMClientInterface::startImpl()
emit started();
}
void LLMClientInterface::handleFullResponse(const QString &requestId, const QString &fullText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
const RequestContext &ctx = it.value();
sendCompletionToClient(fullText, ctx.originalRequest, true);
m_activeRequests.erase(it);
m_performanceLogger.endTimeMeasurement(requestId);
}
void LLMClientInterface::handleRequestFailed(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));
m_activeRequests.erase(it);
}
void LLMClientInterface::sendData(const QByteArray &data)
{
QJsonDocument doc = QJsonDocument::fromJson(data);
@ -91,7 +111,7 @@ void LLMClientInterface::sendData(const QByteArray &data)
m_performanceLogger.startTimeMeasurement(requestId);
handleCompletion(request);
} else if (method == "$/cancelRequest") {
handleCancelRequest(request);
handleCancelRequest();
} else if (method == "exit") {
// TODO make exit handler
} else {
@ -99,14 +119,29 @@ void LLMClientInterface::sendData(const QByteArray &data)
}
}
void LLMClientInterface::handleCancelRequest(const QJsonObject &request)
void LLMClientInterface::handleCancelRequest()
{
QString id = request["params"].toObject()["id"].toString();
if (m_requestHandler.cancelRequest(id)) {
LOG_MESSAGE(QString("Request %1 cancelled successfully").arg(id));
} else {
LOG_MESSAGE(QString("Request %1 not found").arg(id));
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());
}
}
m_activeRequests.clear();
LOG_MESSAGE("All requests cancelled and state cleared");
}
void LLMClientInterface::handleInitialize(const QJsonObject &request)
@ -203,15 +238,12 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
config.promptTemplate = promptTemplate;
// TODO refactor networking
if (provider->providerID() == LLMCore::ProviderID::GoogleAI) {
QString stream = m_completeSettings.stream() ? QString{"streamGenerateContent?alt=sse"}
: QString{"generateContent?"};
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,
promptTemplate->type() == LLMCore::TemplateType::FIM ? provider->completionEndpoint()
: provider->chatEndpoint()));
config.providerRequest = {{"model", modelName}, {"stream", m_completeSettings.stream()}};
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();
@ -227,6 +259,18 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
&& 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());
@ -264,7 +308,8 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
config.providerRequest,
promptTemplate,
updatedContext,
LLMCore::RequestType::CodeCompletion);
LLMCore::RequestType::CodeCompletion,
false);
auto errors = config.provider->validateRequest(config.providerRequest, promptTemplate->type());
if (!errors.isEmpty()) {
@ -272,7 +317,26 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
LOG_MESSAGES(errors);
return;
}
m_requestHandler.sendLLMRequest(config, request);
QString requestId = request["id"].toString();
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(
@ -289,6 +353,26 @@ 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;
@ -318,16 +402,28 @@ void LLMClientInterface::sendCompletionToClient(
LOG_MESSAGE(QString("Completions before filter: \n%1").arg(completion));
QString processedCompletion
= promptTemplate->type() == LLMCore::TemplateType::Chat
&& m_completeSettings.smartProcessInstuctText()
? CodeHandler::processText(completion, Context::extractFilePathFromRequest(request))
: completion;
QString outputHandler = m_completeSettings.modelOutputHandler.stringValue();
QString processedCompletion;
if (outputHandler == "Raw text") {
processedCompletion = completion;
} else if (outputHandler == "Force processing") {
processedCompletion = CodeHandler::processText(completion,
Context::extractFilePathFromRequest(request));
} else { // "Auto"
processedCompletion = CodeHandler::hasCodeBlocks(completion)
? CodeHandler::processText(completion,
Context::extractFilePathFromRequest(
request))
: completion;
}
completionItem[LanguageServerProtocol::textKey] = processedCompletion;
QJsonObject range;
range["start"] = position;
range["end"] = position;
QJsonObject end = position;
end["character"] = position["character"].toInt() + processedCompletion.length();
range["end"] = end;
completionItem[LanguageServerProtocol::rangeKey] = range;
completionItem[LanguageServerProtocol::positionKey] = position;
completions.append(completionItem);

View File

@ -28,7 +28,6 @@
#include <llmcore/ContextData.hpp>
#include <llmcore/IPromptProvider.hpp>
#include <llmcore/IProviderRegistry.hpp>
#include <llmcore/RequestHandler.hpp>
#include <logger/IRequestPerformanceLogger.hpp>
#include <settings/CodeCompletionSettings.hpp>
#include <settings/GeneralSettings.hpp>
@ -48,9 +47,9 @@ public:
const Settings::CodeCompletionSettings &completeSettings,
LLMCore::IProviderRegistry &providerRegistry,
LLMCore::IPromptProvider *promptProvider,
LLMCore::RequestHandlerBase &requestHandler,
Context::IDocumentReader &documentReader,
IRequestPerformanceLogger &performanceLogger);
~LLMClientInterface() override;
Utils::FilePath serverDeviceTemplate() const override;
@ -67,26 +66,37 @@ 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);
void handleTextDocumentDidOpen(const QJsonObject &request);
void handleInitialized(const QJsonObject &request);
void handleExit(const QJsonObject &request);
void handleCancelRequest(const QJsonObject &request);
void handleCancelRequest();
struct RequestContext
{
QJsonObject originalRequest;
LLMCore::Provider *provider;
};
LLMCore::ContextData prepareContext(
const QJsonObject &request, const Context::DocumentInfo &documentInfo);
QString endpoint(LLMCore::Provider *provider, LLMCore::TemplateType type, bool isLanguageSpecify);
const Settings::CodeCompletionSettings &m_completeSettings;
const Settings::GeneralSettings &m_generalSettings;
LLMCore::IPromptProvider *m_promptProvider = nullptr;
LLMCore::IProviderRegistry &m_providerRegistry;
LLMCore::RequestHandlerBase &m_requestHandler;
Context::IDocumentReader &m_documentReader;
IRequestPerformanceLogger &m_performanceLogger;
QElapsedTimer m_completionTimer;
Context::ContextManager *m_contextManager;
QHash<QString, RequestContext> m_activeRequests;
};
} // namespace QodeAssist

View File

@ -29,6 +29,36 @@
namespace QodeAssist {
QString mergeWithRightText(const QString &suggestion, const QString &rightText)
{
if (suggestion.isEmpty() || rightText.isEmpty()) {
return suggestion;
}
int j = 0;
QString processed = rightText;
QSet<int> matchedPositions;
for (int i = 0; i < suggestion.length() && j < processed.length(); ++i) {
if (suggestion[i] == processed[j]) {
matchedPositions.insert(j);
++j;
}
}
if (matchedPositions.isEmpty()) {
return suggestion + rightText;
}
QList<int> positions = matchedPositions.values();
std::sort(positions.begin(), positions.end(), std::greater<int>());
for (int pos : positions) {
processed.remove(pos, 1);
}
return suggestion;
}
LLMSuggestion::LLMSuggestion(
const QList<Data> &suggestions, QTextDocument *sourceDocument, int currentCompletion)
: TextEditor::CyclicSuggestion(suggestions, sourceDocument, currentCompletion)
@ -43,16 +73,23 @@ LLMSuggestion::LLMSuggestion(
QTextCursor cursor(sourceDocument);
cursor.setPosition(startPos);
cursor.setPosition(endPos, QTextCursor::KeepAnchor);
QTextBlock block = cursor.block();
QString blockText = block.text();
int startPosInBlock = startPos - block.position();
int endPosInBlock = endPos - block.position();
int cursorPositionInBlock = cursor.positionInBlock();
blockText.replace(startPosInBlock, endPosInBlock - startPosInBlock, data.text);
replacementDocument()->setPlainText(blockText);
QString rightText = blockText.mid(cursorPositionInBlock);
if (!data.text.contains('\n')) {
QString processedRightText = mergeWithRightText(data.text, rightText);
processedRightText = processedRightText.mid(data.text.length());
QString displayText = blockText.left(cursorPositionInBlock) + data.text
+ processedRightText;
replacementDocument()->setPlainText(displayText);
} else {
QString displayText = blockText.left(cursorPositionInBlock) + data.text;
replacementDocument()->setPlainText(displayText);
}
}
bool LLMSuggestion::applyWord(TextEditor::TextEditorWidget *widget)
@ -77,31 +114,87 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
int next = part == Word ? Utils::endOfNextWord(text, startPos) : text.indexOf('\n', startPos);
if (next == -1)
return apply();
if (next == -1) {
if (part == Line) {
next = text.length();
} else {
return apply();
}
}
if (part == Line)
++next;
QString subText = text.mid(startPos, next - startPos);
if (subText.isEmpty())
if (subText.isEmpty()) {
return false;
}
currentCursor.insertText(subText);
if (!subText.contains('\n')) {
currentCursor.insertText(subText);
if (const int seperatorPos = subText.lastIndexOf('\n'); seperatorPos >= 0) {
const QString newCompletionText = text.mid(startPos + seperatorPos + 1);
if (!newCompletionText.isEmpty()) {
const Utils::Text::Position newStart{int(range.begin.line + subText.count('\n')), 0};
const QString remainingText = text.mid(next);
if (!remainingText.isEmpty()) {
QTextCursor newCursor = widget->textCursor();
const Utils::Text::Position newStart = Utils::Text::Position::fromPositionInDocument(
newCursor.document(), newCursor.position());
const Utils::Text::Position
newEnd{newStart.line, int(subText.length() - seperatorPos - 1)};
newEnd{newStart.line, newStart.column + int(remainingText.length())};
const Utils::Text::Range newRange{newStart, newEnd};
const QList<Data> newSuggestion{{newRange, newEnd, newCompletionText}};
const QList<Data> newSuggestion{{newRange, newStart, remainingText}};
widget->insertSuggestion(
std::make_unique<LLMSuggestion>(newSuggestion, widget->document(), 0));
}
} else {
currentCursor.insertText(subText);
if (const int seperatorPos = subText.lastIndexOf('\n'); seperatorPos >= 0) {
const QString newCompletionText = text.mid(startPos + seperatorPos + 1);
if (!newCompletionText.isEmpty()) {
const Utils::Text::Position newStart{int(range.begin.line + subText.count('\n')), 0};
const Utils::Text::Position newEnd{newStart.line, int(newCompletionText.length())};
const Utils::Text::Range newRange{newStart, newEnd};
const QList<Data> newSuggestion{{newRange, newEnd, newCompletionText}};
widget->insertSuggestion(
std::make_unique<LLMSuggestion>(newSuggestion, widget->document(), 0));
}
}
}
return false;
}
bool LLMSuggestion::apply()
{
const Utils::Text::Range range = suggestions()[currentSuggestion()].range;
const QTextCursor cursor = range.begin.toTextCursor(sourceDocument());
const QString text = suggestions()[currentSuggestion()].text;
QTextBlock currentBlock = cursor.block();
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
QTextCursor editCursor = cursor;
int firstLineEnd = text.indexOf('\n');
if (firstLineEnd != -1) {
QString firstLine = text.left(firstLineEnd);
QString restOfText = text.mid(firstLineEnd);
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
QString mergedFirstLine = mergeWithRightText(firstLine, textAfterCursor);
editCursor.insertText(mergedFirstLine + restOfText);
} else {
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
QString mergedText = mergeWithRightText(text, textAfterCursor);
editCursor.insertText(mergedText);
}
return true;
}
} // namespace QodeAssist

View File

@ -40,5 +40,6 @@ public:
bool applyWord(TextEditor::TextEditorWidget *widget) override;
bool applyLine(TextEditor::TextEditorWidget *widget) override;
bool applyPart(Part part, TextEditor::TextEditorWidget *widget);
bool apply() override;
};
} // namespace QodeAssist

View File

@ -1,7 +1,8 @@
{
"Id" : "qodeassist",
"Name" : "QodeAssist",
"Version" : "0.5.9",
"Version" : "0.8.1",
"CompatVersion" : "${IDE_VERSION}",
"Vendor" : "Petr Mironychev",
"VendorId" : "petrmironychev",
"Copyright" : "(C) ${IDE_COPYRIGHT_YEAR} Petr Mironychev, (C) ${IDE_COPYRIGHT_YEAR} The Qt Company Ltd",

View File

@ -8,5 +8,7 @@
<file>resources/images/improve-current-code-icon.png</file>
<file>resources/images/suggest-new-icon.png</file>
<file>resources/images/suggest-new-icon@2x.png</file>
<file>resources/images/qode-assist-chat-icon.png</file>
<file>resources/images/qode-assist-chat-icon@2x.png</file>
</qresource>
</RCC>

View File

@ -24,6 +24,8 @@
#pragma once
#include <QObject>
#include "LLMClientInterface.hpp"
#include "LSPCompletion.hpp"
#include "QuickRefactorHandler.hpp"
@ -37,6 +39,7 @@ namespace QodeAssist {
class QodeAssistClient : public LanguageClient::Client
{
Q_OBJECT
public:
explicit QodeAssistClient(LLMClientInterface *clientInterface);
~QodeAssistClient() override;

View File

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="en_001"></TS>

View File

@ -28,6 +28,8 @@
#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 <settings/GeneralSettings.hpp>
@ -36,30 +38,10 @@ namespace QodeAssist {
QuickRefactorHandler::QuickRefactorHandler(QObject *parent)
: QObject(parent)
, m_requestHandler(new LLMCore::RequestHandler(this))
, m_currentEditor(nullptr)
, m_isRefactoringInProgress(false)
, m_contextManager(this)
{
connect(
m_requestHandler,
&LLMCore::RequestHandler::completionReceived,
this,
&QuickRefactorHandler::handleLLMResponse);
connect(
m_requestHandler,
&LLMCore::RequestHandler::requestFinished,
this,
[this](const QString &requestId, bool success, const QString &errorString) {
if (!success && requestId == m_lastRequestId) {
m_isRefactoringInProgress = false;
RefactorResult result;
result.success = false;
result.errorMessage = errorString;
emit refactoringCompleted(result);
}
});
}
QuickRefactorHandler::~QuickRefactorHandler() {}
@ -153,18 +135,21 @@ void QuickRefactorHandler::prepareAndSendRequest(
}
LLMCore::LLMConfig config;
config.requestType = LLMCore::RequestType::Chat;
config.requestType = LLMCore::RequestType::QuickRefactoring;
config.provider = provider;
config.promptTemplate = promptTemplate;
config.url = QString("%1%2").arg(settings.caUrl(), provider->chatEndpoint());
config.providerRequest
= {{"model", settings.caModel()}, {"stream", Settings::chatAssistantSettings().stream()}};
config.providerRequest = {{"model", settings.caModel()}, {"stream", true}};
config.apiKey = provider->apiKey();
LLMCore::ContextData context = prepareContext(editor, range, instructions);
provider
->prepareRequest(config.providerRequest, promptTemplate, context, LLMCore::RequestType::Chat);
provider->prepareRequest(
config.providerRequest,
promptTemplate,
context,
LLMCore::RequestType::QuickRefactoring,
false);
QString requestId = QUuid::createUuid().toString();
m_lastRequestId = requestId;
@ -172,7 +157,23 @@ void QuickRefactorHandler::prepareAndSendRequest(
m_isRefactoringInProgress = true;
m_requestHandler->sendLLMRequest(config, request);
m_activeRequests[requestId] = {request, provider};
connect(
provider,
&LLMCore::Provider::fullResponseReceived,
this,
&QuickRefactorHandler::handleFullResponse,
Qt::UniqueConnection);
connect(
provider,
&LLMCore::Provider::requestFailed,
this,
&QuickRefactorHandler::handleRequestFailed,
Qt::UniqueConnection);
provider->sendRequest(requestId, config.url, config.providerRequest);
}
LLMCore::ContextData QuickRefactorHandler::prepareContext(
@ -210,6 +211,18 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
}
QString systemPrompt = Settings::codeCompletionSettings().quickRefactorSystemPrompt();
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;
@ -280,7 +293,17 @@ void QuickRefactorHandler::handleLLMResponse(
void QuickRefactorHandler::cancelRequest()
{
if (m_isRefactoringInProgress) {
m_requestHandler->cancelRequest(m_lastRequestId);
auto id = m_lastRequestId;
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;
}
}
m_isRefactoringInProgress = false;
RefactorResult result;
@ -290,4 +313,23 @@ void QuickRefactorHandler::cancelRequest()
}
}
void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QString &fullText)
{
if (requestId == m_lastRequestId) {
QJsonObject request{{"id", requestId}};
handleLLMResponse(fullText, request, true);
}
}
void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error)
{
if (requestId == m_lastRequestId) {
m_isRefactoringInProgress = false;
RefactorResult result;
result.success = false;
result.errorMessage = error;
emit refactoringCompleted(result);
}
}
} // namespace QodeAssist

View File

@ -27,7 +27,8 @@
#include <context/ContextManager.hpp>
#include <context/IDocumentReader.hpp>
#include <llmcore/RequestHandler.hpp>
#include <llmcore/ContextData.hpp>
#include <llmcore/Provider.hpp>
namespace QodeAssist {
@ -54,6 +55,10 @@ 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,
@ -66,7 +71,13 @@ private:
const Utils::Text::Range &range,
const QString &instructions);
LLMCore::RequestHandler *m_requestHandler;
struct RequestContext
{
QJsonObject originalRequest;
LLMCore::Provider *provider;
};
QHash<QString, RequestContext> m_activeRequests;
TextEditor::TextEditorWidget *m_currentEditor;
Utils::Text::Range m_currentRange;
bool m_isRefactoringInProgress;

113
README.md
View File

@ -2,7 +2,8 @@
[![Build plugin](https://github.com/Palm1r/QodeAssist/actions/workflows/build_cmake.yml/badge.svg?branch=main)](https://github.com/Palm1r/QodeAssist/actions/workflows/build_cmake.yml)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/Palm1r/QodeAssist/total?color=41%2C173%2C71)
![GitHub Tag](https://img.shields.io/github/v/tag/Palm1r/QodeAssist)
![Static Badge](https://img.shields.io/badge/QtCreator-16.0.1-brightgreen)
![Static Badge](https://img.shields.io/badge/QtCreator-16.0.2-brightgreen)
![Static Badge](https://img.shields.io/badge/QtCreator-17.0.2-brightgreen)
[![](https://dcbadge.limes.pink/api/server/BGMkUsXUgf?style=flat)](https://discord.gg/BGMkUsXUgf)
![qodeassist-icon](https://github.com/user-attachments/assets/dc336712-83cb-440d-8761-8d0a31de898d) QodeAssist is an AI-powered coding assistant plugin for Qt Creator. It provides intelligent code completion and suggestions for C++ and QML, leveraging large language models through local providers like Ollama. Enhance your coding productivity with context-aware AI assistance directly in your Qt development environment.
@ -23,10 +24,12 @@
7. [Configure for Ollama](#configure-for-ollama)
8. [Configure for llama.cpp](#configure-for-llamacpp)
9. [System Prompt Configuration](#system-prompt-configuration)
10. [File Context Features](#file-context-features)
11. [QtCreator Version Compatibility](#qtcreator-version-compatibility)
12. [Development Progress](#development-progress)
13. [Hotkeys](#hotkeys)
10. [File Context Feature](#file-context-feature)
11. [Quick Refactoring Feature](#quick-refactoring-feature)
12. [QtCreator Version Compatibility](#qtcreator-version-compatibility)
13. [Development Progress](#development-progress)
14. [Hotkeys](#hotkeys)
15. [Ignoring Files](#ignoring-files)
14. [Troubleshooting](#troubleshooting)
15. [Support the Development](#support-the-development-of-qodeassist)
16. [How to Build](#how-to-build)
@ -37,7 +40,8 @@
- Sharing IDE opened files with model context (disabled by default, need enable in settings)
- Quick refactor code via fast chat command and opened files
- Chat functionality:
- Side and Bottom panels
- Side and Bottom panels(enabling in chat settings due stability reason with QQuickWidget problem)
- Chat in additional popup window with pinning(recommended)
- Chat history autosave and restore
- Token usage monitoring and management
- Attach files for one-time code analysis
@ -51,9 +55,10 @@
- LM Studio
- Mistral AI
- Google AI
- OpenAI-compatible providers(eg. llama.cpp, https://openrouter.ai)
- OpenAI-compatible providers (eg. llama.cpp, https://openrouter.ai)
- Extensive library of model-specific templates
- Easy configuration and model selection
- Support tools/function calling (enabled by default)
Join our Discord Community: Have questions or want to discuss QodeAssist? Join our [Discord server](https://discord.gg/BGMkUsXUgf) to connect with other users and get support!
@ -82,11 +87,21 @@ Join our Discord Community: Have questions or want to discuss QodeAssist? Join o
<img width="326" alt="QodeAssistBottomPanel" src="https://github.com/user-attachments/assets/4cc64c23-a294-4df8-9153-39ad6fdab34b">
</details>
<details>
<summary>Chat in addtional window: (click to expand)</summary>
<img width="851" height="865" alt="image" src="https://github.com/user-attachments/assets/a68894b7-886e-4501-a61b-7161ae34b427" />
</details>
<details>
<summary>Automatic syncing with open editor files: (click to expand)</summary>
<img width="600" alt="OpenedDocumentsSync" src="https://github.com/user-attachments/assets/08efda2f-dc4d-44c3-927c-e6a975090d2f">
</details>
<details>
<summary>Example how tools works: (click to expand)</summary>
<img width="600" alt="ToolsDemo" src="https://github.com/user-attachments/assets/cf6273ad-d5c8-47fc-81e6-23d929547f6c">
</details>
## Install plugin to QtCreator
1. Install Latest Qt Creator
2. Download the QodeAssist plugin for your Qt Creator
@ -177,6 +192,7 @@ ollama run qwen2.5-coder:32b
- The URL is set to http://localhost:11434
- Your installed model appears in the model selection
- The prompt template is Ollama Auto FIM or Ollama Auto Chat for chat assistance. You can specify template if it is not work correct
- Disable using tools if your model doesn't support tooling
4. Click Apply if you made any changes
You're all set! QodeAssist is now ready to use in Qt Creator.
@ -192,6 +208,7 @@ You're all set! QodeAssist is now ready to use in Qt Creator.
- Set the llama.cpp URL (e.g. http://localhost:8080)
- Fill in model name
- Choose template for model(e.g. llama.cpp FIM for any model with FIM support)
- Disable using tools if your model doesn't support tooling
<details>
<summary>Example of llama.cpp settings: (click to expand)</summary>
<img width="829" alt="llama.cpp Settings" src="https://github.com/user-attachments/assets/8c75602c-60f3-49ed-a7a9-d3c972061ea2" />
@ -201,7 +218,35 @@ You're all set! QodeAssist is now ready to use in Qt Creator.
The plugin comes with default system prompts optimized for chat and instruct models, as these currently provide better results for code assistance. If you prefer using FIM (Fill-in-Middle) models, you can easily customize the system prompt in the settings.
## File Context Features
## Project Rules Configuration
QodeAssist supports project-specific rules to customize AI behavior for your codebase. Create a `.qodeassist/rules/` directory in your project root.
### Quick Start
```bash
mkdir -p .qodeassist/rules/{common,completion,chat,quickrefactor}
```
```
.qodeassist/
└── rules/
├── common/ # Applied to all contexts
├── completion/ # Code completion only
├── chat/ # Chat assistant only
└── quickrefactor/ # Quick refactor only
```
All .md files in each directory are automatically loaded and added to the system prompt.
Example
Create .qodeassist/rules/common/general.md:
```markdown
# Project Guidelines
- Use snake_case for private members
- Prefix interfaces with 'I'
- Always document public APIs
- Prefer Qt containers over STL
```
## File Context Feature
QodeAssist provides two powerful ways to include source code files in your chat conversations: Attachments and Linked Files. Each serves a distinct purpose and helps provide better context for the AI assistant.
@ -234,9 +279,23 @@ Linked files provide persistent context throughout the conversation:
- Supports automatic syncing with open editor files (can be enabled in settings)
- Files can be added/removed at any time during the conversation
## Quick Refactoring Feature
### Setup
Since this is actually a small chat with redirected output, the main settings of the provider, model and template are taken from the chat settings
### Using
The request to model consist of instructions to model, selection code and cursor position
The default instruction is: "Refactor the code to improve its quality and maintainability." and sending if text field is empty
Also there buttons to quick call instractions:
* Repeat latest instruction, will activate after sending first request in QtCreator session
* Improve current selection code
* Suggestion alternative variant of selection code
* Other instructions[TBD]
## QtCreator Version Compatibility
- QtCreator 16.0.1 - 0.5.7 - 0.x.x
- QtCreator 17.0.0 - 0.6.0 - 0.x.x
- QtCreator 16.0.2 - 0.5.13 - 0.x.x
- QtCreator 16.0.1 - 0.5.7 - 0.5.13
- QtCreator 16.0.0 - 0.5.2 - 0.5.6
- QtCreator 15.0.1 - 0.4.8 - 0.5.1
- QtCreator 15.0.0 - 0.4.0 - 0.4.7
@ -251,9 +310,19 @@ Linked files provide persistent context throughout the conversation:
- [x] Sharing diff with model
- [ ] Sharing project source with model
- [ ] Support for more providers and models
- [ ] Support MCP
## Hotkeys
All hotkeys available in QtCreator Settings
Also you can find default hotkeys here:
- To call chat with llm in separate window, you can use:
- on Mac: Option + Command + W
- on Windows: Ctrl + Alt + W
- on Linux: Ctrl + Alt + W
- To close chat with llm in separate window, you can use:
- on Mac: Option + Command + S
- on Windows: Ctrl + Alt + S
- on Linux: Ctrl + Alt + S
- To call manual request to suggestion, you can use or change it in settings
- on Mac: Option + Command + Q
- on Windows: Ctrl + Alt + Q
@ -264,6 +333,30 @@ Linked files provide persistent context throughout the conversation:
- on Mac: Option + Command + R
- on Windows: Ctrl + Alt + R
- on Linux with KDE Plasma: Ctrl + Alt + R
## Ignoring Files
QodeAssist supports the ability to ignore files in context using a .qodeassistignore file. This allows you to exclude specific files from the context during code completion and in the chat assistant, which is especially useful for large projects.
### How to Use .qodeassistignore
- Create a .qodeassistignore file in the root directory of your project near CMakeLists.txt or pro.
- Add patterns for files and directories that should be excluded from the context.
- QodeAssist will automatically detect this file and apply the exclusion rules.
### .qodeassistignore File Format
The file format is similar to .gitignore:
- Each pattern is written on a separate line
- Empty lines are ignored
- Lines starting with # are considered comments
- Standard wildcards work the same as in .gitignore
- To negate a pattern, use ! at the beginning of the line
```
# Ignore all files in the build directory
/build
*.tmp
# Ignore a specific file
src/generated/autogen.cpp
```
## Troubleshooting

14
TaskFlow/CMakeLists.txt Normal file
View File

@ -0,0 +1,14 @@
add_subdirectory(core)
add_subdirectory(Editor)
# add_subdirectory(serialization)
# add_subdirectory(tasks)
qt_add_library(TaskFlow STATIC)
target_link_libraries(TaskFlow
PUBLIC
TaskFlowCore
TaskFlowEditorplugin
# TaskFlowSerialization
# TaskFlowTasks
)

View File

@ -0,0 +1,41 @@
qt_add_library(TaskFlowEditor STATIC)
qt_policy(SET QTP0001 NEW)
qt_policy(SET QTP0004 NEW)
qt_add_qml_module(TaskFlowEditor
URI TaskFlow.Editor
VERSION 1.0
DEPENDENCIES QtQuick
RESOURCES
QML_FILES
qml/FlowEditorView.qml
qml/Flow.qml
qml/Task.qml
qml/TaskPort.qml
qml/TaskParameter.qml
qml/TaskConnection.qml
SOURCES
FlowEditor.hpp FlowEditor.cpp
FlowsModel.hpp FlowsModel.cpp
TaskItem.hpp TaskItem.cpp
FlowItem.hpp FlowItem.cpp
TaskModel.hpp TaskModel.cpp
TaskPortItem.hpp TaskPortItem.cpp
TaskPortModel.hpp TaskPortModel.cpp
TaskConnectionsModel.hpp TaskConnectionsModel.cpp
TaskConnectionItem.hpp TaskConnectionItem.cpp
GridBackground.hpp GridBackground.cpp
)
target_link_libraries(TaskFlowEditor
PUBLIC
Qt::Quick
PRIVATE
TaskFlowCore
)
target_include_directories(TaskFlowEditor
PUBLIC
${CMAKE_CURRENT_LIST_DIR}
)

View File

@ -0,0 +1,120 @@
/*
* 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:
*/
#include "FlowEditor.hpp"
namespace QodeAssist::TaskFlow {
FlowEditor::FlowEditor(QQuickItem *parent)
: QQuickItem(parent)
{}
void FlowEditor::initialize()
{
emit availableTaskTypesChanged();
emit availableFlowsChanged();
m_flowsModel = new FlowsModel(m_flowManager, this);
emit flowsModelChanged();
if (m_flowsModel->rowCount() > 0) {
setCurrentFlowIndex(0);
}
// setCurrentFlowId(m_flowManager->flows().begin().value()->flowId());
m_currentFlow = m_flowManager->getFlow();
emit currentFlowChanged();
}
QString FlowEditor::currentFlowId() const
{
return m_currentFlowId;
}
void FlowEditor::setCurrentFlowId(const QString &newCurrentFlowId)
{
if (m_currentFlowId == newCurrentFlowId)
return;
m_currentFlowId = newCurrentFlowId;
emit currentFlowIdChanged();
}
QStringList FlowEditor::availableTaskTypes() const
{
if (m_flowManager)
return m_flowManager->getAvailableTasksTypes();
else {
return {"No flow manager"};
}
}
QStringList FlowEditor::availableFlows() const
{
if (m_flowManager) {
auto flows = m_flowManager->getAvailableFlows();
return flows.size() > 0 ? flows : QStringList{"No flows"};
} else {
return {"No flow manager"};
}
}
void FlowEditor::setFlowManager(FlowManager *newFlowManager)
{
if (m_flowManager == newFlowManager)
return;
m_flowManager = newFlowManager;
initialize();
}
FlowsModel *FlowEditor::flowsModel() const
{
return m_flowsModel;
}
int FlowEditor::currentFlowIndex() const
{
return m_currentFlowIndex;
}
void FlowEditor::setCurrentFlowIndex(int newCurrentFlowIndex)
{
if (m_currentFlowIndex == newCurrentFlowIndex)
return;
m_currentFlowIndex = newCurrentFlowIndex;
emit currentFlowIndexChanged();
}
Flow *FlowEditor::getFlow(const QString &flowName)
{
return m_flowManager->getFlow(flowName);
}
Flow *FlowEditor::getCurrentFlow()
{
return m_flowManager->getFlow(m_currentFlowId);
}
Flow *FlowEditor::currentFlow() const
{
return m_currentFlow;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,86 @@
/*
* 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:
*/
#pragma once
#include <QQuickItem>
#include "FlowsModel.hpp"
#include <FlowManager.hpp>
namespace QodeAssist::TaskFlow {
class FlowEditor : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
QString currentFlowId READ currentFlowId WRITE setCurrentFlowId NOTIFY currentFlowIdChanged)
Q_PROPERTY(
QStringList availableTaskTypes READ availableTaskTypes NOTIFY availableTaskTypesChanged)
Q_PROPERTY(QStringList availableFlows READ availableFlows NOTIFY availableFlowsChanged)
Q_PROPERTY(FlowsModel *flowsModel READ flowsModel NOTIFY flowsModelChanged)
Q_PROPERTY(int currentFlowIndex READ currentFlowIndex WRITE setCurrentFlowIndex NOTIFY
currentFlowIndexChanged)
Q_PROPERTY(Flow *currentFlow READ currentFlow NOTIFY currentFlowChanged FINAL)
public:
FlowEditor(QQuickItem *parent = nullptr);
void initialize();
QString currentFlowId() const;
void setCurrentFlowId(const QString &newCurrentFlowId);
QStringList availableTaskTypes() const;
QStringList availableFlows() const;
void setFlowManager(FlowManager *newFlowManager);
FlowsModel *flowsModel() const;
int currentFlowIndex() const;
void setCurrentFlowIndex(int newCurrentFlowIndex);
Q_INVOKABLE Flow *getFlow(const QString &flowName);
Q_INVOKABLE Flow *getCurrentFlow();
Flow *currentFlow() const;
signals:
void currentFlowIdChanged();
void availableTaskTypesChanged();
void availableFlowsChanged();
void flowsModelChanged();
void currentFlowIndexChanged();
void currentFlowChanged();
private:
FlowManager *m_flowManager = nullptr;
QString m_currentFlowId;
FlowsModel *m_flowsModel;
int m_currentFlowIndex;
Flow *m_currentFlow = nullptr;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,90 @@
#include "FlowItem.hpp"
namespace QodeAssist::TaskFlow {
FlowItem::FlowItem(QQuickItem *parent)
: QQuickItem(parent)
{
connect(this, &QQuickItem::childrenChanged, this, [this]() { updateFlowLayout(); });
}
QString FlowItem::flowId() const
{
if (!m_flow)
return {"no flow"};
return m_flow->flowId();
}
void FlowItem::setFlowId(const QString &newFlowId)
{
if (m_flow->flowId() == newFlowId)
return;
m_flow->setFlowId(newFlowId);
emit flowIdChanged();
}
Flow *FlowItem::flow() const
{
return m_flow;
}
void FlowItem::setFlow(Flow *newFlow)
{
if (m_flow == newFlow)
return;
m_flow = newFlow;
emit flowChanged();
emit flowIdChanged();
qDebug() << "FlowItem::setFlow" << m_flow->flowId() << newFlow;
m_taskModel = new TaskModel(m_flow, this);
m_connectionsModel = new TaskConnectionsModel(m_flow, this);
emit taskModelChanged();
emit connectionsModelChanged();
}
TaskModel *FlowItem::taskModel() const
{
return m_taskModel;
}
TaskConnectionsModel *FlowItem::connectionsModel() const
{
return m_connectionsModel;
}
QVariantList FlowItem::taskItems() const
{
return m_taskItems;
}
void FlowItem::setTaskItems(const QVariantList &newTaskItems)
{
qDebug() << "FlowItem::setTaskItems" << newTaskItems;
if (m_taskItems == newTaskItems)
return;
m_taskItems = newTaskItems;
emit taskItemsChanged();
}
void FlowItem::updateFlowLayout()
{
auto allItems = this->childItems();
for (auto child : allItems) {
if (child->objectName() == QString("TaskItem")) {
qDebug() << "Found TaskItem:" << child;
auto taskItem = qobject_cast<TaskItem *>(child);
m_taskItemsList.insert(taskItem, taskItem->task());
}
if (child->objectName() == QString("TaskConnectionItem")) {
qDebug() << "Found TaskConnectionItem:" << child;
auto connectionItem = qobject_cast<TaskConnectionItem *>(child);
m_taskConnectionsList.insert(connectionItem, connectionItem->connection());
}
}
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,61 @@
#pragma once
#include <QQuickItem>
#include "TaskConnectionItem.hpp"
#include "TaskConnectionsModel.hpp"
#include "TaskItem.hpp"
#include "TaskModel.hpp"
#include <Flow.hpp>
#include <TaskConnection.hpp>
namespace QodeAssist::TaskFlow {
class FlowItem : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString flowId READ flowId WRITE setFlowId NOTIFY flowIdChanged)
Q_PROPERTY(Flow *flow READ flow WRITE setFlow NOTIFY flowChanged)
Q_PROPERTY(TaskModel *taskModel READ taskModel NOTIFY taskModelChanged)
Q_PROPERTY(
TaskConnectionsModel *connectionsModel READ connectionsModel NOTIFY connectionsModelChanged)
Q_PROPERTY(QVariantList taskItems READ taskItems WRITE setTaskItems NOTIFY taskItemsChanged)
public:
explicit FlowItem(QQuickItem *parent = nullptr);
QString flowId() const;
void setFlowId(const QString &newFlowId);
Flow *flow() const;
void setFlow(Flow *newFlow);
TaskModel *taskModel() const;
TaskConnectionsModel *connectionsModel() const;
QVariantList taskItems() const;
void setTaskItems(const QVariantList &newTaskItems);
void updateFlowLayout();
signals:
void flowIdChanged();
void flowChanged();
void taskModelChanged();
void connectionsModelChanged();
void taskItemsChanged();
private:
Flow *m_flow = nullptr;
TaskModel *m_taskModel = nullptr;
TaskConnectionsModel *m_connectionsModel = nullptr;
QVariantList m_taskItems;
QHash<TaskItem *, BaseTask *> m_taskItemsList;
QHash<TaskConnectionItem *, TaskConnection *> m_taskConnectionsList;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,54 @@
#include "FlowsModel.hpp"
#include "FlowManager.hpp"
namespace QodeAssist::TaskFlow {
FlowsModel::FlowsModel(FlowManager *flowManager, QObject *parent)
: QAbstractListModel(parent)
, m_flowManager(flowManager)
{
connect(m_flowManager, &FlowManager::flowAdded, this, &FlowsModel::onFlowAdded);
}
int FlowsModel::rowCount(const QModelIndex &parent) const
{
return m_flowManager->flows().size();
}
QVariant FlowsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || !m_flowManager || index.row() >= m_flowManager->flows().size())
return QVariant();
const auto flows = m_flowManager->flows().values();
switch (role) {
case FlowRoles::FlowIdRole:
return flows.at(index.row())->flowId();
case FlowRoles::FlowDataRole:
return QVariant::fromValue(flows.at(index.row()));
default:
return QVariant();
}
}
QHash<int, QByteArray> FlowsModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[FlowRoles::FlowIdRole] = "flowId";
roles[FlowRoles::FlowDataRole] = "flowData";
return roles;
}
void FlowsModel::onFlowAdded(const QString &flowId)
{
// qDebug() << "FlowsModel::Flow added: " << flowId;
// int newIndex = m_flowManager->flows().size();
// beginInsertRows(QModelIndex(), newIndex, newIndex);
// endInsertRows();
}
void FlowsModel::onFlowRemoved(const QString &flowId) {}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,31 @@
#pragma once
#include <QAbstractListModel>
#include <QObject>
// #include "tasks/Flow.hpp"
#include <FlowManager.hpp>
namespace QodeAssist::TaskFlow {
class FlowsModel : public QAbstractListModel
{
Q_OBJECT
public:
enum FlowRoles { FlowIdRole = Qt::UserRole, FlowDataRole };
FlowsModel(FlowManager *flowManager, QObject *parent = nullptr);
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;
public slots:
void onFlowAdded(const QString &flowId);
void onFlowRemoved(const QString &flowId);
private:
FlowManager *m_flowManager;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,98 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "GridBackground.hpp"
#include <QPainter>
#include <QPixmap>
#include <QQuickWindow>
#include <QSGSimpleRectNode>
#include <QSGSimpleTextureNode>
namespace QodeAssist::TaskFlow {
GridBackground::GridBackground(QQuickItem *parent)
: QQuickItem(parent)
{
setFlag(QQuickItem::ItemHasContents, true);
}
int GridBackground::gridSize() const
{
return m_gridSize;
}
void GridBackground::setGridSize(int size)
{
if (m_gridSize != size) {
m_gridSize = size;
update();
emit gridSizeChanged();
}
}
QColor GridBackground::gridColor() const
{
return m_gridColor;
}
void GridBackground::setGridColor(const QColor &color)
{
if (m_gridColor != color) {
m_gridColor = color;
update();
emit gridColorChanged();
}
}
QSGNode *GridBackground::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
{
QSGSimpleTextureNode *node = static_cast<QSGSimpleTextureNode *>(oldNode);
if (!node) {
node = new QSGSimpleTextureNode();
}
QPixmap pixmap(width(), height());
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, false);
QPen pen(m_gridColor);
pen.setWidth(1);
painter.setPen(pen);
painter.setOpacity(this->opacity());
for (int x = 0; x < width(); x += m_gridSize) {
painter.drawLine(x, 0, x, height());
}
for (int y = 0; y < height(); y += m_gridSize) {
painter.drawLine(0, y, width(), y);
}
painter.end();
QSGTexture *texture = window()->createTextureFromImage(pixmap.toImage());
node->setTexture(texture);
node->setRect(boundingRect());
return node;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QColor>
#include <QPainter>
#include <QQuickItem>
namespace QodeAssist::TaskFlow {
class GridBackground : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int gridSize READ gridSize WRITE setGridSize NOTIFY gridSizeChanged)
Q_PROPERTY(QColor gridColor READ gridColor WRITE setGridColor NOTIFY gridColorChanged)
public:
explicit GridBackground(QQuickItem *parent = nullptr);
int gridSize() const;
void setGridSize(int size);
QColor gridColor() const;
void setGridColor(const QColor &color);
signals:
void gridSizeChanged();
void gridColorChanged();
protected:
QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override;
private:
int m_gridSize = 20;
QColor m_gridColor = QColor(128, 128, 128);
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,153 @@
#include "TaskConnectionItem.hpp"
#include "TaskItem.hpp"
#include "TaskPortItem.hpp"
#include <QDebug>
namespace QodeAssist::TaskFlow {
TaskConnectionItem::TaskConnectionItem(QQuickItem *parent)
: QQuickItem(parent)
{
setObjectName("TaskConnectionItem");
}
void TaskConnectionItem::setConnection(TaskConnection *connection)
{
if (m_connection == connection)
return;
m_connection = connection;
emit connectionChanged();
calculatePositions();
}
void TaskConnectionItem::updatePositions()
{
// calculatePositions();
}
void TaskConnectionItem::calculatePositions()
{
if (!m_connection) {
return;
}
// Find source task item
QQuickItem *sourceTaskItem = findTaskItem(m_connection->sourceTask());
QQuickItem *targetTaskItem = findTaskItem(m_connection->targetTask());
if (!sourceTaskItem || !targetTaskItem) {
return;
}
// Find port items within tasks
QQuickItem *sourcePortItem = findPortItem(sourceTaskItem, m_connection->sourcePort());
QQuickItem *targetPortItem = findPortItem(targetTaskItem, m_connection->targetPort());
if (!sourcePortItem || !targetPortItem) {
return;
}
// Calculate global positions
QPointF sourceGlobal
= sourcePortItem
->mapToItem(parentItem(), sourcePortItem->width() / 2, sourcePortItem->height() / 2);
QPointF targetGlobal
= targetPortItem
->mapToItem(parentItem(), targetPortItem->width() / 2, targetPortItem->height() / 2);
if (m_startPoint != sourceGlobal) {
m_startPoint = sourceGlobal;
emit startPointChanged();
}
if (m_endPoint != targetGlobal) {
m_endPoint = targetGlobal;
emit endPointChanged();
}
}
QQuickItem *TaskConnectionItem::findTaskItem(BaseTask *task)
{
for (const QVariant &item : m_taskItems) {
QQuickItem *taskItem = qvariant_cast<QQuickItem *>(item);
if (!taskItem)
continue;
QVariant taskProp = taskItem->property("task");
if (taskProp.isValid() && taskProp.value<BaseTask *>() == task) {
return taskItem;
}
}
return nullptr;
}
QQuickItem *TaskConnectionItem::findTaskItemRecursive(QQuickItem *item, BaseTask *task)
{
// Проверяем objectName и task property
if (item->objectName() == "TaskItem") {
QVariant taskProp = item->property("task");
if (taskProp.isValid()) {
BaseTask *itemTask = taskProp.value<BaseTask *>();
if (itemTask == task) {
return item;
}
}
}
// Рекурсивно ищем в детях
auto children = item->childItems();
for (QQuickItem *child : children) {
if (QQuickItem *found = findTaskItemRecursive(child, task)) {
return found;
}
}
return nullptr;
}
QQuickItem *TaskConnectionItem::findPortItem(QQuickItem *taskItem, TaskPort *port)
{
std::function<QQuickItem *(QQuickItem *)> findPortRecursive =
[&](QQuickItem *item) -> QQuickItem * {
// Проверяем objectName и port property
if (item->objectName() == "TaskPortItem") {
QVariant portProp = item->property("port");
if (portProp.isValid()) {
TaskPort *itemPort = portProp.value<TaskPort *>();
if (itemPort == port) {
return item;
}
}
}
// Рекурсивно ищем в детях
for (QQuickItem *child : item->childItems()) {
if (QQuickItem *found = findPortRecursive(child)) {
return found;
}
}
return nullptr;
};
return findPortRecursive(taskItem);
}
QVariantList TaskConnectionItem::taskItems() const
{
return m_taskItems;
}
void TaskConnectionItem::setTaskItems(const QVariantList &newTaskItems)
{
if (m_taskItems == newTaskItems)
return;
m_taskItems = newTaskItems;
emit taskItemsChanged();
calculatePositions();
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,55 @@
#pragma once
#include "TaskConnection.hpp"
#include <QPointF>
#include <QQuickItem>
namespace QodeAssist::TaskFlow {
class TaskConnectionItem : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QPointF startPoint READ startPoint NOTIFY startPointChanged)
Q_PROPERTY(QPointF endPoint READ endPoint NOTIFY endPointChanged)
Q_PROPERTY(
TaskConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
Q_PROPERTY(QVariantList taskItems READ taskItems WRITE setTaskItems NOTIFY taskItemsChanged)
public:
TaskConnectionItem(QQuickItem *parent = nullptr);
QPointF startPoint() const { return m_startPoint; }
QPointF endPoint() const { return m_endPoint; }
TaskConnection *connection() const { return m_connection; }
void setConnection(TaskConnection *connection);
Q_INVOKABLE void updatePositions();
QVariantList taskItems() const;
void setTaskItems(const QVariantList &newTaskItems);
signals:
void startPointChanged();
void endPointChanged();
void connectionChanged();
void taskItemsChanged();
private:
void calculatePositions();
QQuickItem *findTaskItem(BaseTask *task);
QQuickItem *findTaskItemRecursive(QQuickItem *item, BaseTask *task);
QQuickItem *findPortItem(QQuickItem *taskItem, TaskPort *port);
private:
TaskConnection *m_connection = nullptr;
QPointF m_startPoint;
QPointF m_endPoint;
QVariantList m_taskItems;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,29 @@
#include "TaskConnectionsModel.hpp"
namespace QodeAssist::TaskFlow {
TaskConnectionsModel::TaskConnectionsModel(Flow *flow, QObject *parent)
: QAbstractListModel(parent)
, m_flow(flow)
{}
int TaskConnectionsModel::rowCount(const QModelIndex &parent) const
{
return m_flow->connections().size();
}
QVariant TaskConnectionsModel::data(const QModelIndex &index, int role) const
{
if (role == TaskConnectionsRoles::TaskConnectionsRole)
return QVariant::fromValue(m_flow->connections().at(index.row()));
return QVariant();
}
QHash<int, QByteArray> TaskConnectionsModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[TaskConnectionsRoles::TaskConnectionsRole] = "connectionData";
return roles;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,25 @@
#pragma once
#include <QAbstractListModel>
#include <QObject>
#include <Flow.hpp>
namespace QodeAssist::TaskFlow {
class TaskConnectionsModel : public QAbstractListModel
{
public:
enum TaskConnectionsRoles { TaskConnectionsRole = Qt::UserRole };
explicit TaskConnectionsModel(Flow *flow, QObject *parent = nullptr);
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;
private:
Flow *m_flow;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,69 @@
#include "TaskItem.hpp"
namespace QodeAssist::TaskFlow {
TaskItem::TaskItem(QQuickItem *parent)
: QQuickItem(parent)
{
setObjectName("TaskItem");
}
QString TaskItem::taskId() const
{
return m_taskId;
}
void TaskItem::setTaskId(const QString &newTaskId)
{
if (m_taskId == newTaskId)
return;
m_taskId = newTaskId;
emit taskIdChanged();
}
QString TaskItem::taskType() const
{
return m_task ? m_task->taskType() : QString();
}
BaseTask *TaskItem::task() const
{
return m_task;
}
void TaskItem::setTask(BaseTask *newTask)
{
if (m_task == newTask)
return;
m_task = newTask;
if (m_task) {
m_taskId = m_task->taskId();
// Обновляем модели портов
m_inputPorts = new TaskPortModel(m_task->getInputPorts(), this);
m_outputPorts = new TaskPortModel(m_task->getOutputPorts(), this);
} else {
m_inputPorts = nullptr;
m_outputPorts = nullptr;
}
emit taskChanged();
emit inputPortsChanged();
emit outputPortsChanged();
emit taskIdChanged();
emit taskTypeChanged();
}
TaskPortModel *TaskItem::inputPorts() const
{
return m_inputPorts;
}
TaskPortModel *TaskItem::outputPorts() const
{
return m_outputPorts;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,49 @@
#pragma once
#include <QQuickItem>
#include "TaskPortModel.hpp"
#include <BaseTask.hpp>
#include <TaskPort.hpp>
namespace QodeAssist::TaskFlow {
class TaskItem : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString taskId READ taskId WRITE setTaskId NOTIFY taskIdChanged)
Q_PROPERTY(QString taskType READ taskType NOTIFY taskTypeChanged)
Q_PROPERTY(BaseTask *task READ task WRITE setTask NOTIFY taskChanged)
Q_PROPERTY(TaskPortModel *inputPorts READ inputPorts NOTIFY inputPortsChanged)
Q_PROPERTY(TaskPortModel *outputPorts READ outputPorts NOTIFY outputPortsChanged)
public:
TaskItem(QQuickItem *parent = nullptr);
QString taskId() const;
void setTaskId(const QString &newTaskId);
QString taskType() const;
BaseTask *task() const;
void setTask(BaseTask *newTask);
TaskPortModel *inputPorts() const;
TaskPortModel *outputPorts() const;
signals:
void taskIdChanged();
void taskTypeChanged();
void taskChanged();
void inputPortsChanged();
void outputPortsChanged();
private:
QString m_taskId;
BaseTask *m_task = nullptr;
TaskPortModel *m_inputPorts = nullptr;
TaskPortModel *m_outputPorts = nullptr;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,40 @@
#include "TaskModel.hpp"
namespace QodeAssist::TaskFlow {
TaskModel::TaskModel(Flow *flow, QObject *parent)
: QAbstractListModel(parent)
, m_flow(flow)
{}
int TaskModel::rowCount(const QModelIndex &parent) const
{
return m_flow->tasks().size();
}
QVariant TaskModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || !m_flow || index.row() >= m_flow->tasks().size())
return QVariant();
const auto &task = m_flow->tasks().values();
switch (role) {
case TaskRoles::TaskIdRole:
return task.at(index.row())->taskId();
case TaskRoles::TaskDataRole:
return QVariant::fromValue(task.at(index.row()));
default:
return QVariant();
}
}
QHash<int, QByteArray> TaskModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[TaskRoles::TaskIdRole] = "taskId";
roles[TaskRoles::TaskDataRole] = "taskData";
return roles;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,25 @@
#pragma once
#include <QAbstractListModel>
#include <Flow.hpp>
namespace QodeAssist::TaskFlow {
class TaskModel : public QAbstractListModel
{
Q_OBJECT
public:
enum TaskRoles { TaskIdRole = Qt::UserRole, TaskDataRole };
TaskModel(Flow *flow, QObject *parent);
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;
private:
Flow *m_flow;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,29 @@
#include "TaskPortItem.hpp"
namespace QodeAssist::TaskFlow {
TaskPortItem::TaskPortItem(QQuickItem *parent)
: QQuickItem(parent)
{
setObjectName("TaskPortItem");
}
TaskPort *TaskPortItem::port() const
{
return m_port;
}
void TaskPortItem::setPort(TaskPort *newPort)
{
if (m_port == newPort)
return;
m_port = newPort;
emit portChanged();
}
QString TaskPortItem::name() const
{
return m_port->name();
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,31 @@
#pragma once
#include <TaskPort.hpp>
#include <QQuickItem>
namespace QodeAssist::TaskFlow {
class TaskPortItem : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(TaskPort *port READ port WRITE setPort NOTIFY portChanged)
Q_PROPERTY(QString name READ name CONSTANT)
public:
TaskPortItem(QQuickItem *parent = nullptr);
TaskPort *port() const;
void setPort(TaskPort *newPort);
QString name() const;
signals:
void portChanged();
private:
TaskPort *m_port = nullptr;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,39 @@
#include "TaskPortModel.hpp"
#include "TaskPort.hpp"
namespace QodeAssist::TaskFlow {
TaskPortModel::TaskPortModel(const QList<TaskPort *> &ports, QObject *parent)
: QAbstractListModel(parent)
, m_ports(ports)
{}
int TaskPortModel::rowCount(const QModelIndex &parent) const
{
return m_ports.size();
}
QVariant TaskPortModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_ports.size())
return QVariant();
switch (role) {
case TaskPortRoles::TaskPortNameRole:
return m_ports.at(index.row())->name();
case TaskPortRoles::TaskPortDataRole:
return QVariant::fromValue(m_ports.at(index.row()));
default:
return QVariant();
}
}
QHash<int, QByteArray> TaskPortModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[TaskPortRoles::TaskPortNameRole] = "taskPortName";
roles[TaskPortRoles::TaskPortDataRole] = "taskPortData";
return roles;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,25 @@
#pragma once
#include <QAbstractListModel>
#include <BaseTask.hpp>
namespace QodeAssist::TaskFlow {
class TaskPortModel : public QAbstractListModel
{
Q_OBJECT
public:
enum TaskPortRoles { TaskPortNameRole = Qt::UserRole, TaskPortDataRole };
TaskPortModel(const QList<TaskPort *> &ports, QObject *parent = nullptr);
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;
private:
QList<TaskPort *> m_ports;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,134 @@
import QtQuick
import TaskFlow.Editor
FlowItem {
id: root
Repeater {
id: tasks
model: root.taskModel
delegate: Task {
// task: taskData
}
}
Repeater {
id: connections
model: root.taskModel
delegate: TaskConnection {
// task: taskData
}
}
// property var qtaskItems: []
// // Flow container background
// Rectangle {
// anchors.fill: parent
// color: palette.alternateBase
// border.color: palette.mid
// border.width: 2
// radius: 8
// // Flow header
// Rectangle {
// id: flowHeader
// anchors.top: parent.top
// anchors.left: parent.left
// anchors.right: parent.right
// height: 40
// color: palette.button
// radius: 6
// Rectangle {
// anchors.bottom: parent.bottom
// anchors.left: parent.left
// anchors.right: parent.right
// height: parent.radius
// color: parent.color
// }
// Text {
// anchors.centerIn: parent
// text: root.flowId
// color: palette.buttonText
// font.pixelSize: 14
// font.bold: true
// }
// }
// // // Tasks container
// // Row {
// // id: tasksRow
// // anchors.top: flowHeader.bottom
// // anchors.left: parent.left
// // anchors.margins: 25
// // anchors.topMargin: 25
// // objectName: "FlowTaskRow"
// // spacing: 40
// // Repeater {
// // model: root.taskModel
// // delegate: Task {
// // task: taskData
// // }
// // onItemAdded: function(index, item){
// // console.log("task added", index, item)
// // qtaskItems.push(item)
// // root.insertTaskItem(index, item)
// // }
// // onItemRemoved: function(index, item){
// // console.log("task added", index, item)
// // var idx = qtaskItems.indexOf(item)
// // if (idx !== -1) qtaskItems.splice(idx, 1)
// // }
// // }
// // }
// // Repeater {
// // model: root.connectionsModel
// // delegate: TaskConnection {
// // connection: connectionData
// // }
// // }
// }
// // Flow info tooltip
// Rectangle {
// id: infoTooltip
// anchors.top: parent.bottom
// anchors.left: parent.left
// anchors.topMargin: 5
// width: infoText.width + 20
// height: infoText.height + 10
// color: palette.base
// border.color: palette.shadow
// border.width: 1
// radius: 4
// visible: false
// Text {
// id: infoText
// anchors.centerIn: parent
// text: "Tasks: " + (root.taskModel ? root.taskModel.rowCount() : 0)
// color: palette.text
// font.pixelSize: 10
// }
// }
// MouseArea {
// anchors.fill: parent
// hoverEnabled: true
// onEntered: infoTooltip.visible = true
// onExited: infoTooltip.visible = false
// }
}

View File

@ -0,0 +1,140 @@
/*
* 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 TaskFlow.Editor
FlowEditor {
id: root
width: 1200
height: 800
property SystemPalette sysPalette: SystemPalette {
colorGroup: SystemPalette.Active
}
palette {
window: sysPalette.window
windowText: sysPalette.windowText
base: sysPalette.base
alternateBase: sysPalette.alternateBase
text: sysPalette.text
button: sysPalette.button
buttonText: sysPalette.buttonText
highlight: sysPalette.highlight
highlightedText: sysPalette.highlightedText
light: sysPalette.light
mid: sysPalette.mid
dark: sysPalette.dark
shadow: sysPalette.shadow
brightText: sysPalette.brightText
}
// Background with grid pattern
Rectangle {
anchors.fill: parent
color: palette.window
// Grid pattern using C++ implementation
GridBackground {
anchors.fill: parent
gridSize: 20
gridColor: palette.mid
opacity: 0.3
}
}
// Header panel
Rectangle {
id: headerPanel
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 60
color: palette.base
border.color: palette.mid
border.width: 1
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 20
spacing: 20
Text {
text: "Flow Editor"
color: palette.windowText
font.pixelSize: 18
font.bold: true
}
Rectangle {
width: 2
height: 30
color: palette.mid
}
Text {
text: "Flow:"
color: palette.text
font.pixelSize: 14
}
ComboBox {
id: flowComboBox
model: root.flowsModel
textRole: "flowId"
currentIndex: root.currentFlowIndex
onActivated: {
root.currentFlowIndex = currentIndex
}
}
Text {
text: "Available Tasks: " + root.availableTaskTypes.join(", ")
color: palette.text
font.pixelSize: 12
}
}
}
// Main flow area
ScrollView {
id: scrollView
anchors.top: headerPanel.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
contentWidth: flow.width
contentHeight: flow.height
Flow {
id: flow
// flow: root.currentFlow
width: Math.max(root.width, 0)
height: Math.min(root.height, 0)
}
}
}

View File

@ -0,0 +1,210 @@
import QtQuick
import TaskFlow.Editor
TaskItem{
id: root
width: 280
height: Math.max(200, contentColumn.height + 40)
DragHandler {
id: dragHandler
target: root
onActiveChanged: {
if (active) {
root.z = 1000; // Поднять над остальными
} else {
root.z = 0;
}
}
}
// Task node background
Rectangle {
anchors.fill: parent
color: palette.window
border.color: palette.shadow
border.width: 1
radius: 6
// Task header
Rectangle {
id: taskHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 40
color: palette.button
radius: 6
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: parent.radius
color: parent.color
}
Text {
anchors.centerIn: parent
// text: root.taskType
color: palette.buttonText
font.pixelSize: 14
font.bold: true
}
}
// Task content
Column {
id: contentColumn
anchors.top: taskHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 10
spacing: 8
// Task ID
Text {
text: "ID: " + root.taskId
color: palette.text
font.pixelSize: 11
width: parent.width
elide: Text.ElideRight
}
// Parameters section
Item {
width: parent.width
height: paramColumn.height
// visible: root.parameters && root.parameters.rowCount() > 0
Column {
id: paramColumn
width: parent.width
spacing: 6
Text {
text: "Parameters:"
color: palette.text
font.pixelSize: 10
font.bold: true
}
Repeater {
model: root.parameters
delegate: Rectangle {
width: parent.width
height: 24
color: palette.base
radius: 4
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 8
spacing: 6
Text {
text: paramKey + ":"
color: palette.text
font.pixelSize: 9
font.bold: true
}
Text {
text: paramValue
color: palette.windowText
font.pixelSize: 9
width: Math.min(150, implicitWidth)
elide: Text.ElideRight
}
}
}
}
}
}
}
}
// Input ports section (left side)
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: -8
spacing: 6
// visible: root.inputPorts && root.inputPorts.rowCount() > 0
// Input label
Text {
text: "IN"
color: palette.highlight
font.pixelSize: 10
font.bold: true
anchors.left: parent.left
anchors.leftMargin: -20
}
// Repeater {
// model: root.inputPorts
// delegate: Row {
// spacing: 6
// Text {
// text: taskPortName
// color: palette.text
// font.pixelSize: 9
// anchors.verticalCenter: parent.verticalCenter
// horizontalAlignment: Text.AlignRight
// width: 60
// elide: Text.ElideLeft
// }
// TaskPort {
// port: taskPortData
// isInput: true
// }
// }
// }
}
// Output ports section (right side)
Column {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: -10
spacing: 8
// visible: root.outputPorts && root.outputPorts.rowCount() > 0
// Output label
Text {
text: "OUT"
color: palette.highlight
font.pixelSize: 10
font.bold: true
anchors.right: parent.right
anchors.rightMargin: -24
}
// Repeater {
// model: root.outputPorts
// delegate: Row {
// spacing: 6
// TaskPort {
// port: taskPortData
// isInput: false
// }
// Text {
// text: taskPortName
// color: palette.text
// font.pixelSize: 9
// anchors.verticalCenter: parent.verticalCenter
// width: 60
// elide: Text.ElideRight
// }
// }
// }
}
}

View File

@ -0,0 +1,68 @@
import QtQuick
import QtQuick.Shapes
import TaskFlow.Editor
TaskConnectionItem {
id: root
property color connectionColor: "red"
Rectangle {
width: 10
height: 10
radius: width / 2
color: "blue"
}
// width: Math.abs(endPoint.x - startPoint.x) + 40
// height: Math.abs(endPoint.y - startPoint.y) + 40
// x: Math.min(startPoint.x, endPoint.x) - 20
// y: Math.min(startPoint.y, endPoint.y) - 20
// Shape {
// anchors.fill: parent
// ShapePath {
// strokeWidth: 2
// strokeColor: connectionColor
// fillColor: "transparent"
// property point localStart: Qt.point(
// root.startPoint.x - root.x,
// root.startPoint.y - root.y
// )
// property point localEnd: Qt.point(
// root.endPoint.x - root.x,
// root.endPoint.y - root.y
// )
// // Bezier curve
// property real controlOffset: Math.max(50, Math.abs(localEnd.x - localStart.x) * 0.4)
// startX: localStart.x
// startY: localStart.y
// PathCubic {
// x: parent.localEnd.x
// y: parent.localEnd.y
// control1X: parent.localStart.x + parent.controlOffset
// control1Y: parent.localStart.y
// control2X: parent.localEnd.x - parent.controlOffset
// control2Y: parent.localEnd.y
// }
// }
// // Arrow head
// Rectangle {
// width: 8
// height: 8
// color: connectionColor
// rotation: 45
// x: root.endPoint.x - root.x - 4
// y: root.endPoint.y - root.y - 4
// }
// }
// // Update positions when tasks might have moved
// Component.onCompleted: updatePositions()
}

View File

@ -0,0 +1,6 @@
import QtQuick
import TaskFlow.Editor
Item {
}

View File

@ -0,0 +1,63 @@
import QtQuick
import TaskFlow.Editor
TaskPortItem {
id: root
property bool isInput: true
width: 20
height: 20
// Port circle
Rectangle {
id: portCircle
anchors.centerIn: parent
width: 16
height: 16
radius: 8
color: getPortColor()
border.color: palette.windowText
border.width: 1
// Inner circle for connected state simulation
Rectangle {
anchors.centerIn: parent
width: 8
height: 8
radius: 4
color: root.port ? palette.windowText : "transparent"
visible: root.port !== null
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
portCircle.scale = 1.3
portCircle.border.width = 2
}
onExited: {
portCircle.scale = 1.0
portCircle.border.width = 1
}
}
function getPortColor() {
if (!root.port) return palette.mid
// Different colors for input/output using system palette
if (root.isInput) {
return palette.highlight // System highlight color for inputs
} else {
return Qt.lighter(palette.highlight, 1.3) // Lighter highlight for outputs
}
}
Behavior on scale {
NumberAnimation { duration: 100 }
}
}

117
TaskFlow/core/BaseTask.cpp Normal file
View File

@ -0,0 +1,117 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "BaseTask.hpp"
#include "TaskPort.hpp"
#include <QUuid>
#include <QtConcurrent>
namespace QodeAssist::TaskFlow {
BaseTask::BaseTask(QObject *parent)
: QObject(parent)
, m_taskId("unknown" + QUuid::createUuid().toString())
{}
BaseTask::~BaseTask()
{
qDeleteAll(m_inputs);
qDeleteAll(m_outputs);
}
QString BaseTask::taskId() const
{
return m_taskId;
}
void BaseTask::setTaskId(const QString &taskId)
{
m_taskId = taskId;
}
QString BaseTask::taskType() const
{
return QString(metaObject()->className()).split("::").last();
}
void BaseTask::addInputPort(const QString &name)
{
QMutexLocker locker(&m_tasksMutex);
m_inputs.append(new TaskPort(name, TaskPort::ValueType::Any, this));
}
void BaseTask::addOutputPort(const QString &name)
{
QMutexLocker locker(&m_tasksMutex);
m_outputs.append(new TaskPort(name, TaskPort::ValueType::Any, this));
}
TaskPort *BaseTask::inputPort(const QString &name) const
{
QMutexLocker locker(&m_tasksMutex);
auto it = std::find_if(m_inputs.begin(), m_inputs.end(), [&name](const TaskPort *port) {
return port->name() == name;
});
return (it != m_inputs.end()) ? *it : nullptr;
}
TaskPort *BaseTask::outputPort(const QString &name) const
{
QMutexLocker locker(&m_tasksMutex);
auto it = std::find_if(m_outputs.begin(), m_outputs.end(), [&name](const TaskPort *port) {
return port->name() == name;
});
return (it != m_outputs.end()) ? *it : nullptr;
}
QList<TaskPort *> BaseTask::getInputPorts() const
{
QMutexLocker locker(&m_tasksMutex);
return m_inputs;
}
QList<TaskPort *> BaseTask::getOutputPorts() const
{
QMutexLocker locker(&m_tasksMutex);
return m_outputs;
}
QFuture<TaskState> BaseTask::executeAsync()
{
return QtConcurrent::task([this]() -> TaskState { return execute(); }).spawn();
}
QString BaseTask::taskStateAsString(TaskState state)
{
switch (state) {
case TaskState::Success:
return "Success";
case TaskState::Failed:
return "Failed";
case TaskState::Cancelled:
return "Cancelled";
}
return "Unknown";
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,68 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QFuture>
#include <QMetaType>
#include <QMutex>
#include <QObject>
namespace QodeAssist::TaskFlow {
class TaskPort;
enum class TaskState { Success, Failed, Cancelled };
class BaseTask : public QObject
{
Q_OBJECT
public:
explicit BaseTask(QObject *parent = nullptr);
virtual ~BaseTask();
QString taskId() const;
void setTaskId(const QString &taskId);
QString taskType() const;
void addInputPort(const QString &name);
void addOutputPort(const QString &name);
TaskPort *inputPort(const QString &name) const;
TaskPort *outputPort(const QString &name) const;
QList<TaskPort *> getInputPorts() const;
QList<TaskPort *> getOutputPorts() const;
virtual TaskState execute() = 0;
static QString taskStateAsString(TaskState state);
protected:
QFuture<TaskState> executeAsync();
private:
QString m_taskId;
QList<TaskPort *> m_inputs;
QList<TaskPort *> m_outputs;
mutable QMutex m_tasksMutex;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,22 @@
qt_add_library(TaskFlowCore STATIC
BaseTask.hpp BaseTask.cpp
TaskConnection.hpp TaskConnection.cpp
Flow.hpp Flow.cpp
TaskPort.hpp TaskPort.cpp
TaskRegistry.hpp TaskRegistry.cpp
FlowManager.hpp FlowManager.cpp
FlowRegistry.hpp FlowRegistry.cpp
)
target_link_libraries(TaskFlowCore
PUBLIC
Qt::Core
Qt::Concurrent
PRIVATE
QodeAssistLogger
)
target_include_directories(TaskFlowCore
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)

355
TaskFlow/core/Flow.cpp Normal file
View File

@ -0,0 +1,355 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "Flow.hpp"
#include "TaskPort.hpp"
#include <QUuid>
#include <QtConcurrent>
namespace QodeAssist::TaskFlow {
Flow::Flow(QObject *parent)
: QObject(parent)
, m_flowId("flow_" + QUuid::createUuid().toString())
{}
Flow::~Flow()
{
QMutexLocker locker(&m_flowMutex);
qDeleteAll(m_connections);
qDeleteAll(m_tasks);
}
QString Flow::flowId() const
{
return m_flowId;
}
void Flow::setFlowId(const QString &flowId)
{
if (m_flowId != flowId) {
m_flowId = flowId;
}
}
void Flow::addTask(BaseTask *task)
{
if (!task) {
return;
}
QMutexLocker locker(&m_flowMutex);
QString taskId = task->taskId();
if (m_tasks.contains(taskId)) {
qWarning() << "Flow::addTask - Task with ID" << taskId << "already exists";
return;
}
m_tasks.insert(taskId, task);
task->setParent(this);
emit taskAdded(taskId);
}
void Flow::removeTask(const QString &taskId)
{
QMutexLocker locker(&m_flowMutex);
BaseTask *task = m_tasks.value(taskId);
if (!task) {
return;
}
auto it = m_connections.begin();
while (it != m_connections.end()) {
TaskConnection *connection = *it;
if (connection->sourceTask() == task || connection->targetTask() == task) {
it = m_connections.erase(it);
emit connectionRemoved(connection);
delete connection;
} else {
++it;
}
}
m_tasks.remove(taskId);
emit taskRemoved(taskId);
delete task;
}
void Flow::removeTask(BaseTask *task)
{
if (!task) {
return;
}
removeTask(task->taskId());
}
BaseTask *Flow::getTask(const QString &taskId) const
{
QMutexLocker locker(&m_flowMutex);
return m_tasks.value(taskId);
}
bool Flow::hasTask(const QString &taskId) const
{
QMutexLocker locker(&m_flowMutex);
return m_tasks.contains(taskId);
}
QHash<QString, BaseTask *> Flow::tasks() const
{
QMutexLocker locker(&m_flowMutex);
return m_tasks;
}
TaskConnection *Flow::addConnection(TaskPort *sourcePort, TaskPort *targetPort)
{
if (!sourcePort || !targetPort) {
qWarning() << "Flow::addConnection - Invalid ports";
return nullptr;
}
// Verify ports belong to tasks in this flow
BaseTask *sourceTask = qobject_cast<BaseTask *>(sourcePort->parent());
BaseTask *targetTask = qobject_cast<BaseTask *>(targetPort->parent());
if (!sourceTask || !targetTask) {
qWarning() << "Flow::addConnection - Ports don't belong to valid tasks";
return nullptr;
}
QMutexLocker locker(&m_flowMutex);
if (!m_tasks.contains(sourceTask->taskId()) || !m_tasks.contains(targetTask->taskId())) {
qWarning() << "Flow::addConnection - Tasks not in this flow";
return nullptr;
}
for (TaskConnection *existingConnection : m_connections) {
if (existingConnection->sourcePort() == sourcePort
&& existingConnection->targetPort() == targetPort) {
qWarning() << "Flow::addConnection - Connection already exists";
return existingConnection;
}
}
TaskConnection *connection = new TaskConnection(sourcePort, targetPort, this);
m_connections.append(connection);
emit connectionAdded(connection);
return connection;
}
void Flow::removeConnection(TaskConnection *connection)
{
if (!connection) {
return;
}
QMutexLocker locker(&m_flowMutex);
if (m_connections.removeOne(connection)) {
emit connectionRemoved(connection);
delete connection;
}
}
QList<TaskConnection *> Flow::connections() const
{
QMutexLocker locker(&m_flowMutex);
return m_connections;
}
QFuture<FlowState> Flow::executeAsync()
{
return QtConcurrent::run([this]() { return execute(); });
}
FlowState Flow::execute()
{
emit executionStarted();
if (!isValid()) {
emit executionFinished(FlowState::Failed);
return FlowState::Failed;
}
if (hasCircularDependencies()) {
qWarning() << "Flow::execute - Circular dependencies detected";
emit executionFinished(FlowState::Failed);
return FlowState::Failed;
}
QList<BaseTask *> executionOrder = getExecutionOrder();
for (BaseTask *task : executionOrder) {
TaskState taskResult = task->execute();
if (taskResult == TaskState::Failed) {
qWarning() << "Flow::execute - Task" << task->taskId() << "failed";
emit executionFinished(FlowState::Failed);
return FlowState::Failed;
}
if (taskResult == TaskState::Cancelled) {
qWarning() << "Flow::execute - Task" << task->taskId() << "cancelled";
emit executionFinished(FlowState::Cancelled);
return FlowState::Cancelled;
}
}
emit executionFinished(FlowState::Success);
return FlowState::Success;
}
bool Flow::isValid() const
{
QMutexLocker locker(&m_flowMutex);
// Check all connections are valid
for (TaskConnection *connection : m_connections) {
if (!connection->isValid()) {
return false;
}
}
return true;
}
bool Flow::hasCircularDependencies() const
{
return detectCircularDependencies();
}
QString Flow::flowStateAsString(FlowState state)
{
switch (state) {
case FlowState::Success:
return "Success";
case FlowState::Failed:
return "Failed";
case FlowState::Cancelled:
return "Cancelled";
}
return "Unknown";
}
QStringList Flow::getTaskIds() const
{
QMutexLocker locker(&m_flowMutex);
return m_tasks.keys();
}
QList<BaseTask *> Flow::getExecutionOrder() const
{
QMutexLocker locker(&m_flowMutex);
QList<BaseTask *> result;
QSet<BaseTask *> visited;
QList<BaseTask *> allTasks = m_tasks.values();
std::function<void(BaseTask *)> visit = [&](BaseTask *task) {
if (visited.contains(task)) {
return;
}
visited.insert(task);
QList<BaseTask *> dependencies = getTaskDependencies(task);
for (BaseTask *dependency : dependencies) {
visit(dependency);
}
result.append(task);
};
for (BaseTask *task : allTasks) {
visit(task);
}
return result;
}
bool Flow::detectCircularDependencies() const
{
QMutexLocker locker(&m_flowMutex);
QSet<BaseTask *> visited;
QSet<BaseTask *> recursionStack;
bool hasCycle = false;
for (BaseTask *task : m_tasks.values()) {
if (!visited.contains(task)) {
visitTask(task, visited, recursionStack, hasCycle);
if (hasCycle) {
return true;
}
}
}
return false;
}
void Flow::visitTask(
BaseTask *task, QSet<BaseTask *> &visited, QSet<BaseTask *> &recursionStack, bool &hasCycle) const
{
if (hasCycle) {
return;
}
visited.insert(task);
recursionStack.insert(task);
for (TaskConnection *connection : m_connections) {
if (connection->sourceTask() == task) {
BaseTask *dependentTask = connection->targetTask();
if (recursionStack.contains(dependentTask)) {
hasCycle = true;
return;
}
if (!visited.contains(dependentTask)) {
visitTask(dependentTask, visited, recursionStack, hasCycle);
}
}
}
recursionStack.remove(task);
}
QList<BaseTask *> Flow::getTaskDependencies(BaseTask *task) const
{
QList<BaseTask *> dependencies;
for (TaskConnection *connection : m_connections) {
if (connection->targetTask() == task) {
BaseTask *dependencyTask = connection->sourceTask();
if (!dependencies.contains(dependencyTask)) {
dependencies.append(dependencyTask);
}
}
}
return dependencies;
}
} // namespace QodeAssist::TaskFlow

95
TaskFlow/core/Flow.hpp Normal file
View File

@ -0,0 +1,95 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QFuture>
#include <QHash>
#include <QList>
#include <QMetaType>
#include <QMutex>
#include <QObject>
#include "BaseTask.hpp"
#include "TaskConnection.hpp"
namespace QodeAssist::TaskFlow {
enum class FlowState { Success, Failed, Cancelled };
class Flow : public QObject
{
Q_OBJECT
public:
explicit Flow(QObject *parent = nullptr);
~Flow() override;
QString flowId() const;
void setFlowId(const QString &flowId);
void addTask(BaseTask *task);
void removeTask(const QString &taskId);
void removeTask(BaseTask *task);
BaseTask *getTask(const QString &taskId) const;
bool hasTask(const QString &taskId) const;
QHash<QString, BaseTask *> tasks() const;
TaskConnection *addConnection(TaskPort *sourcePort, TaskPort *targetPort);
void removeConnection(TaskConnection *connection);
QList<TaskConnection *> connections() const;
QFuture<FlowState> executeAsync();
virtual FlowState execute();
bool isValid() const;
bool hasCircularDependencies() const;
static QString flowStateAsString(FlowState state);
QStringList getTaskIds() const;
signals:
void taskAdded(const QString &taskId);
void taskRemoved(const QString &taskId);
void connectionAdded(QodeAssist::TaskFlow::TaskConnection *connection);
void connectionRemoved(QodeAssist::TaskFlow::TaskConnection *connection);
void executionStarted();
void executionFinished(FlowState result);
private:
QString m_flowId;
QHash<QString, BaseTask *> m_tasks;
QList<TaskConnection *> m_connections;
mutable QMutex m_flowMutex;
QList<BaseTask *> getExecutionOrder() const;
bool detectCircularDependencies() const;
void visitTask(
BaseTask *task,
QSet<BaseTask *> &visited,
QSet<BaseTask *> &recursionStack,
bool &hasCycle) const;
QList<BaseTask *> getTaskDependencies(BaseTask *task) const;
};
} // namespace QodeAssist::TaskFlow
Q_DECLARE_METATYPE(QodeAssist::TaskFlow::Flow *)
Q_DECLARE_METATYPE(QodeAssist::TaskFlow::FlowState)

View File

@ -0,0 +1,112 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "FlowManager.hpp"
#include <Logger.hpp>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include "FlowRegistry.hpp"
#include "TaskRegistry.hpp"
namespace QodeAssist::TaskFlow {
FlowManager::FlowManager(QObject *parent)
: QObject(parent)
, m_taskRegistry(new TaskRegistry(this))
, m_flowRegistry(new FlowRegistry(this))
{
LOG_MESSAGE("FlowManager created");
}
FlowManager::~FlowManager()
{
clear();
}
// Flow *FlowManager::createFlow(const QString &flowId)
// {
// Flow *flow = new Flow(flowId, m_taskRegistry, this);
// if (!m_flows.contains(flow->flowId())) {
// m_flows.insert(flowId, flow);
// } else {
// LOG_MESSAGE(
// QString("FlowManager::createFlow - flow with id %1 already exists").arg(flow->flowId()));
// }
// return flow;
// }
void FlowManager::addFlow(Flow *flow)
{
qDebug() << "FlowManager::addFlow" << flow->flowId();
if (!m_flows.contains(flow->flowId())) {
m_flows.insert(flow->flowId(), flow);
flow->setParent(this);
emit flowAdded(flow->flowId());
} else {
LOG_MESSAGE(
QString("FlowManager::addFlow - flow with id %1 already exists").arg(flow->flowId()));
}
}
void FlowManager::clear()
{
LOG_MESSAGE(QString("FlowManager::clear - removing %1 flows").arg(m_flows.size()));
qDeleteAll(m_flows);
m_flows.clear();
}
QStringList FlowManager::getAvailableTasksTypes()
{
return m_taskRegistry->getAvailableTypes();
}
QStringList FlowManager::getAvailableFlows()
{
return m_flowRegistry->getAvailableTypes();
}
QHash<QString, Flow *> FlowManager::flows() const
{
return m_flows;
}
TaskRegistry *FlowManager::taskRegistry() const
{
return m_taskRegistry;
}
FlowRegistry *FlowManager::flowRegistry() const
{
return m_flowRegistry;
}
Flow *FlowManager::getFlow(const QString &flowId) const
{
// if (flowId.isEmpty()) {
// return m_flows.begin().value();
// }
// return m_flows.value(flowId, nullptr);
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,69 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
#include <QString>
#include "Flow.hpp"
namespace QodeAssist::TaskFlow {
class TaskRegistry;
class FlowRegistry;
class FlowManager : public QObject
{
Q_OBJECT
public:
explicit FlowManager(QObject *parent = nullptr);
~FlowManager() override;
// Flow *createFlow(const QString &flowId);
void addFlow(Flow *flow);
void clear();
QStringList getAvailableTasksTypes();
QStringList getAvailableFlows();
QHash<QString, Flow *> flows() const;
TaskRegistry *taskRegistry() const;
FlowRegistry *flowRegistry() const;
Flow *getFlow(const QString &flowId = {}) const;
signals:
void flowAdded(const QString &flowId);
void flowRemoved(const QString &flowId);
private:
QHash<QString, Flow *> m_flows;
TaskRegistry *m_taskRegistry;
FlowRegistry *m_flowRegistry;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,43 @@
#include "FlowRegistry.hpp"
#include "Logger.hpp"
namespace QodeAssist::TaskFlow {
FlowRegistry::FlowRegistry(QObject *parent)
: QObject(parent)
{}
void FlowRegistry::registerFlow(const QString &flowType, FlowCreator creator)
{
m_flowCreators[flowType] = creator;
LOG_MESSAGE(QString("FlowRegistry: Registered flow type '%1'").arg(flowType));
}
Flow *FlowRegistry::createFlow(const QString &flowType, FlowManager *flowManager) const
{
LOG_MESSAGE(QString("Trying to create flow: %1").arg(flowType));
if (m_flowCreators.contains(flowType)) {
LOG_MESSAGE(QString("Found creator for flow type: %1").arg(flowType));
try {
Flow *flow = m_flowCreators[flowType](flowManager);
if (flow) {
LOG_MESSAGE(QString("Successfully created flow: %1").arg(flowType));
return flow;
}
} catch (...) {
LOG_MESSAGE(QString("Exception while creating flow of type: %1").arg(flowType));
}
} else {
LOG_MESSAGE(QString("No creator found for flow type: %1").arg(flowType));
}
return nullptr;
}
QStringList FlowRegistry::getAvailableTypes() const
{
return m_flowCreators.keys();
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,29 @@
#pragma once
#include <functional>
#include <QHash>
#include <QObject>
#include <QString>
namespace QodeAssist::TaskFlow {
class Flow;
class FlowManager;
class FlowRegistry : public QObject
{
Q_OBJECT
public:
using FlowCreator = std::function<Flow *(FlowManager *flowManager)>;
explicit FlowRegistry(QObject *parent = nullptr);
void registerFlow(const QString &flowType, FlowCreator creator);
Flow *createFlow(const QString &flowType, FlowManager *flowManager = nullptr) const;
QStringList getAvailableTypes() const;
private:
QHash<QString, FlowCreator> m_flowCreators;
};
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,125 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "TaskConnection.hpp"
#include "BaseTask.hpp"
#include "TaskPort.hpp"
#include <QMetaEnum>
namespace QodeAssist::TaskFlow {
TaskConnection::TaskConnection(TaskPort *sourcePort, TaskPort *targetPort, QObject *parent)
: QObject(parent)
, m_sourcePort(sourcePort)
, m_targetPort(targetPort)
{
setupConnection();
}
TaskConnection::~TaskConnection()
{
cleanupConnection();
}
BaseTask *TaskConnection::sourceTask() const
{
return m_sourcePort ? qobject_cast<BaseTask *>(m_sourcePort->parent()) : nullptr;
}
BaseTask *TaskConnection::targetTask() const
{
return m_targetPort ? qobject_cast<BaseTask *>(m_targetPort->parent()) : nullptr;
}
TaskPort *TaskConnection::sourcePort() const
{
return m_sourcePort;
}
TaskPort *TaskConnection::targetPort() const
{
return m_targetPort;
}
bool TaskConnection::isValid() const
{
return m_sourcePort && m_targetPort && m_sourcePort != m_targetPort && sourceTask()
&& targetTask() && sourceTask() != targetTask();
}
bool TaskConnection::isTypeCompatible() const
{
if (!isValid()) {
return false;
}
return m_targetPort->isConnectionTypeCompatible(m_sourcePort);
}
QString TaskConnection::toString() const
{
if (!isValid()) {
return QString();
}
BaseTask *srcTask = sourceTask();
BaseTask *tgtTask = targetTask();
return QString("%1.%2->%3.%4")
.arg(srcTask->taskId())
.arg(m_sourcePort->name())
.arg(tgtTask->taskId())
.arg(m_targetPort->name());
}
bool TaskConnection::operator==(const TaskConnection &other) const
{
return m_sourcePort == other.m_sourcePort && m_targetPort == other.m_targetPort;
}
void TaskConnection::setupConnection()
{
if (!isValid()) {
qWarning() << "TaskConnection::setupConnection - Invalid connection parameters";
return;
}
if (!isTypeCompatible()) {
QMetaEnum metaEnum = QMetaEnum::fromType<TaskPort::ValueType>();
qWarning() << "TaskConnection::setupConnection - Type incompatible connection:"
<< metaEnum.valueToKey(static_cast<int>(m_sourcePort->valueType())) << "to"
<< metaEnum.valueToKey(static_cast<int>(m_targetPort->valueType()));
}
m_sourcePort->setConnection(this);
m_targetPort->setConnection(this);
}
void TaskConnection::cleanupConnection()
{
if (m_sourcePort && m_sourcePort->connection() == this) {
m_sourcePort->setConnection(nullptr);
}
if (m_targetPort && m_targetPort->connection() == this) {
m_targetPort->setConnection(nullptr);
}
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,65 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QString>
namespace QodeAssist::TaskFlow {
class BaseTask;
class TaskPort;
class TaskConnection : public QObject
{
Q_OBJECT
public:
// Constructor automatically sets up the connection
explicit TaskConnection(TaskPort *sourcePort, TaskPort *targetPort, QObject *parent = nullptr);
// Destructor automatically cleans up the connection
~TaskConnection() override;
// Getters
BaseTask *sourceTask() const;
BaseTask *targetTask() const;
TaskPort *sourcePort() const;
TaskPort *targetPort() const;
// Validation
bool isValid() const;
bool isTypeCompatible() const;
// Utility
QString toString() const;
// Comparison
bool operator==(const TaskConnection &other) const;
private:
TaskPort *m_sourcePort;
TaskPort *m_targetPort;
void setupConnection();
void cleanupConnection();
};
} // namespace QodeAssist::TaskFlow

122
TaskFlow/core/TaskPort.cpp Normal file
View File

@ -0,0 +1,122 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "TaskPort.hpp"
#include "TaskConnection.hpp"
#include <QMetaEnum>
namespace QodeAssist::TaskFlow {
TaskPort::TaskPort(const QString &name, ValueType type, QObject *parent)
: QObject(parent)
, m_name(name)
, m_valueType(type)
{}
QString TaskPort::name() const
{
return m_name;
}
void TaskPort::setValueType(ValueType type)
{
if (m_valueType != type)
m_valueType = type;
}
TaskPort::ValueType TaskPort::valueType() const
{
return m_valueType;
}
void TaskPort::setValue(const QVariant &value)
{
if (!isValueTypeCompatible(value)) {
qWarning() << "TaskPort::setValue - Type mismatch for port" << m_name << "Expected:"
<< QMetaEnum::fromType<ValueType>().valueToKey(static_cast<int>(m_valueType))
<< "Got:" << value.typeName();
}
if (m_value != value) {
m_value = value;
emit valueChanged();
}
}
QVariant TaskPort::value() const
{
if (hasConnection() && m_connection->sourcePort()) {
return m_connection->sourcePort()->m_value;
}
return m_value;
}
void TaskPort::setConnection(TaskConnection *connection)
{
if (m_connection != connection) {
m_connection = connection;
emit connectionChanged();
}
}
TaskConnection *TaskPort::connection() const
{
return m_connection;
}
bool TaskPort::hasConnection() const
{
return m_connection != nullptr;
}
bool TaskPort::isValueTypeCompatible(const QVariant &value) const
{
if (m_valueType == ValueType::Any) {
return true;
}
switch (m_valueType) {
case ValueType::String:
return value.canConvert<QString>();
case ValueType::Number:
return value.canConvert<double>() || value.canConvert<int>();
case ValueType::Boolean:
return value.canConvert<bool>();
default:
return false;
}
}
bool TaskPort::isConnectionTypeCompatible(const TaskPort *sourcePort) const
{
if (!sourcePort) {
return false;
}
if (sourcePort->valueType() == ValueType::Any || m_valueType == ValueType::Any) {
return true;
}
return sourcePort->valueType() == m_valueType;
}
} // namespace QodeAssist::TaskFlow

View File

@ -0,0 +1,75 @@
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QString>
#include <QVariant>
#include "TaskConnection.hpp"
namespace QodeAssist::TaskFlow {
class TaskPort : public QObject
{
Q_OBJECT
public:
enum class ValueType {
Any, // QVariant
String, // QString
Number, // int/double
Boolean // bool
};
Q_ENUM(ValueType)
explicit TaskPort(
const QString &name, ValueType type = ValueType::Any, QObject *parent = nullptr);
QString name() const;
ValueType valueType() const;
void setValueType(ValueType type);
void setValue(const QVariant &value);
QVariant value() const;
void setConnection(TaskConnection *connection);
TaskConnection *connection() const;
bool hasConnection() const;
bool isValueTypeCompatible(const QVariant &value) const;
bool isConnectionTypeCompatible(const TaskPort *sourcePort) const;
signals:
void valueChanged();
void connectionChanged();
private:
QString m_name;
ValueType m_valueType;
QVariant m_value;
TaskConnection *m_connection = nullptr;
};
} // namespace QodeAssist::TaskFlow
Q_DECLARE_METATYPE(QodeAssist::TaskFlow::TaskPort *)
Q_DECLARE_METATYPE(QodeAssist::TaskFlow::TaskPort::ValueType)

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