Compare commits

..

1 Commits

Author SHA1 Message Date
Petr Mironychev
17b0eb8186 🐛 fix: Set content type for requests 2024-12-12 15:55:27 +01:00
446 changed files with 4614 additions and 43185 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: ['https://www.paypal.com/paypalme/palm1r', 'https://github.com/Palm1r/QodeAssist#support-the-development-of-qodeassist']
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -2,7 +2,7 @@
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
labels: ''
assignees: ''
---
@@ -23,6 +23,16 @@ A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Log**
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,109 +0,0 @@
{
"name": "QodeAssist",
"vendor": "Petr Mironychev",
"tags": [
"code assistant",
"llm",
"ai"
],
"compatibility": "Qt 6.8.3",
"platforms": [
"Windows",
"macOS",
"Linux"
],
"license": "GPLv3",
"version": "0.5.11",
"status": "draft",
"is_pack": false,
"released_at": null,
"version_history": [
{
"version": "0.4.0",
"is_latest": false,
"released_at": "2024-01-24T15:00:00Z"
},
{
"version": "0.5.2",
"is_latest": false,
"released_at": "2025-03-13T17:00:00Z"
},
{
"version": "0.5.3",
"is_latest": false,
"released_at": "2025-03-14T11:00:00Z"
},
{
"version": "0.5.4",
"is_latest": false,
"released_at": "2025-03-17T03:00:00Z"
},
{
"version": "0.5.5",
"is_latest": false,
"released_at": "2025-03-20T19:00:00Z"
},
{
"version": "0.5.6",
"is_latest": false,
"released_at": "2025-04-04T19:00:00Z"
},
{
"version": "0.5.7",
"is_latest": false,
"released_at": "2025-04-14T01:00:00Z"
},
{
"version": "0.5.8",
"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",
"small_icon": "https://github.com/user-attachments/assets/8ec241bf-3186-452e-b8db-8d70543c2f41",
"description_paragraphs": [
{
"header": "Description",
"text": [
"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."
]
}
],
"description_links": [
{
"url": "https://github.com/Palm1r/QodeAssist",
"link_text": "Site"
}
],
"description_images": [
{
"url": "https://github.com/user-attachments/assets/255a52f1-5cc0-4ca3-b05c-c4cf9cdbe25a",
"image_label": "Code Completion"
}
],
"copyright": "(C) Petr Mironychev",
"download_history": {
"download_count": 0
},
"plugin_sets": []
}

View File

@@ -1,147 +0,0 @@
const fs = require('fs');
const path = require('path');
const updatePluginData = (plugin, env, pluginQtcData) => {
const dictionary_platform = {
'Windows': `${env.PLUGIN_DOWNLOAD_URL}/${env.PLUGIN_NAME}-${env.QT_CREATOR_VERSION}-Windows-x64.7z`,
'Linux': `${env.PLUGIN_DOWNLOAD_URL}/${env.PLUGIN_NAME}-${env.QT_CREATOR_VERSION}-Linux-x64.7z`,
'macOS': `${env.PLUGIN_DOWNLOAD_URL}/${env.PLUGIN_NAME}-${env.QT_CREATOR_VERSION}-macOS-universal.7z`
};
plugin.core_compat_version = env.QT_CREATOR_VERSION_INTERNAL;
plugin.core_version = env.QT_CREATOR_VERSION_INTERNAL;
plugin.status = "draft";
plugin.plugins.forEach(pluginsEntry => {
pluginsEntry.url = dictionary_platform[plugin.host_os];
pluginsEntry.meta_data = pluginQtcData;
});
return plugin;
};
const createNewPluginData = (env, platform, pluginQtcData) => {
const pluginJson = {
"status": "draft",
"core_compat_version": "<placeholder>",
"core_version": "<placeholder>",
"host_os": platform,
"host_os_version": "0", // TODO: pass the real data
"host_os_architecture": "x86_64", // TODO: pass the real data
"plugins": [
{
"url": "",
"size": 5000, // TODO: check if it is needed, pass the real data
"meta_data": {},
"dependencies": []
}
]
};
updatePluginData(pluginJson, env, pluginQtcData);
return pluginJson;
}
const updateServerPluginJson = (endJsonData, pluginQtcData, env) => {
// Update the global data in mainData
endJsonData.name = pluginQtcData.Name;
endJsonData.vendor = pluginQtcData.Vendor;
endJsonData.version = pluginQtcData.Version;
endJsonData.copyright = pluginQtcData.Copyright;
endJsonData.status = "draft";
endJsonData.version_history[0].version = pluginQtcData.Version;
endJsonData.description_paragraphs = [
{
header: "Description",
text: [
pluginQtcData.Description
]
}
];
let found = false;
// Update or Add the plugin data for the current Qt Creator version
for (const plugin of endJsonData.plugin_sets) {
if (plugin.core_compat_version === env.QT_CREATOR_VERSION_INTERNAL) {
updatePluginData(plugin, env, pluginQtcData);
found = true;
}
}
if (!found) {
for (const platform of ['Windows', 'Linux', 'macOS']) {
endJsonData.plugin_sets.push(createNewPluginData(env, platform, pluginQtcData));
}
}
// Save the updated JSON file
const serverPluginJsonPath = path.join(__dirname, `${env.PLUGIN_NAME}.json`);
fs.writeFileSync(serverPluginJsonPath, JSON.stringify(endJsonData, null, 2), 'utf8');
};
const request = async (type, url, token, data) => {
const response = await fetch(url, {
method: type,
headers: {
'Authorization': `Bearer ${token}`,
'accept': 'application/json',
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : undefined
});
if (!response.ok) {
const errorResponse = await response.json();
console.error(`${type} Request Error Response:`, errorResponse); // Log the error response
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
const put = (url, token, data) => request('PUT', url, token, data)
const post = (url, token, data) => request('POST', url, token, data)
const get = (url, token) => request('GET', url, token)
const purgeCache = async (env) => {
try {
await post(`${env.API_URL}api/v1/cache/purgeall`, env.TOKEN, {});
console.log('Cache purged successfully');
} catch (error) {
console.error('Error:', error);
}
};
async function main() {
const env = {
PLUGIN_DOWNLOAD_URL: process.env.PLUGIN_DOWNLOAD_URL || process.argv[2],
PLUGIN_NAME: process.env.PLUGIN_NAME || process.argv[3],
QT_CREATOR_VERSION: process.env.QT_CREATOR_VERSION || process.argv[4],
QT_CREATOR_VERSION_INTERNAL: process.env.QT_CREATOR_VERSION_INTERNAL || process.argv[5],
TOKEN: process.env.TOKEN || process.argv[6],
API_URL: process.env.API_URL || process.argv[7] || ''
};
const pluginQtcData = require(`../../${env.PLUGIN_NAME}-origin/${env.PLUGIN_NAME}.json`);
const templateFileData = require('./plugin.json');
if (env.API_URL === '') {
updateServerPluginJson(templateFileData, pluginQtcData, env);
process.exit(0);
}
const response = await get(`${env.API_URL}api/v1/admin/extensions?search=${env.PLUGIN_NAME}`, env.TOKEN);
if (response.items.length > 0 && response.items[0].extension_id !== '') {
const pluginId = response.items[0].extension_id;
console.log('Plugin found. Updating the plugin');
updateServerPluginJson(response.items[0], pluginQtcData, env);
await put(`${env.API_URL}api/v1/admin/extensions/${pluginId}`, env.TOKEN, response.items[0]);
} else {
console.log('No plugin found. Creating a new plugin');
updateServerPluginJson(templateFileData, pluginQtcData, env);
await post(`${env.API_URL}api/v1/admin/extensions`, env.TOKEN, templateFileData);
}
// await purgeCache(env);
}
main().then(() => console.log('JSON file updated successfully'));

View File

@@ -9,16 +9,18 @@ on:
pull_request:
branches:
- main
env:
PLUGIN_NAME: QodeAssist
QT_VERSION: 6.7.3
QT_CREATOR_VERSION: 14.0.2
QT_CREATOR_SNAPSHOT: NO
MACOS_DEPLOYMENT_TARGET: "11.0"
CMAKE_VERSION: "3.29.6"
NINJA_VERSION: "1.12.1"
jobs:
build:
name: ${{ matrix.config.name }} (Qt ${{ matrix.qt_config.qt_version }}, QtC ${{ matrix.qt_config.qt_creator_version }})
name: ${{ matrix.config.name }}
runs-on: ${{ matrix.config.os }}
outputs:
tag: ${{ steps.git.outputs.tag }}
@@ -28,59 +30,76 @@ jobs:
- {
name: "Windows Latest MSVC", artifact: "Windows-x64",
os: windows-latest,
platform: windows_x64,
cc: "cl", cxx: "cl",
environment_script: "C:/Program Files/Microsoft Visual Studio/2022/Enterprise/VC/Auxiliary/Build/vcvars64.bat",
}
- {
name: "Ubuntu 22.04 GCC", artifact: "Linux-x64",
os: ubuntu-22.04,
platform: linux_x64,
name: "Ubuntu Latest GCC", artifact: "Linux-x64",
os: ubuntu-latest,
cc: "gcc", cxx: "g++"
}
- {
name: "macOS Latest Clang", artifact: "macOS-universal",
os: macos-latest,
platform: mac_x64,
cc: "clang", cxx: "clang++"
}
qt_config:
- {
qt_version: "6.10.1",
qt_creator_version: "18.0.2"
}
- {
qt_version: "6.10.3",
qt_creator_version: "19.0.2"
}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955
with:
submodules: recursive
- uses: actions/checkout@v4
- name: Checkout submodules
id: git
shell: cmake -P {0}
run: |
if (${{github.ref}} MATCHES "tags/v(.*)")
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${CMAKE_MATCH_1}")
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${CMAKE_MATCH_1}\n")
else()
execute_process(
COMMAND git rev-parse --short HEAD
OUTPUT_VARIABLE short_sha
OUTPUT_STRIP_TRAILING_WHITESPACE
)
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${short_sha}")
file(APPEND "$ENV{GITHUB_OUTPUT}" "tag=${{github.run_id}}\n")
endif()
- name: Download Ninja and CMake
uses: lukka/get-cmake@2ecc21724e5215b0e567bc399a2602d2ecb48541
with:
cmakeVersion: ${{ env.CMAKE_VERSION }}
ninjaVersion: ${{ env.NINJA_VERSION }}
shell: cmake -P {0}
run: |
set(cmake_version "$ENV{CMAKE_VERSION}")
set(ninja_version "$ENV{NINJA_VERSION}")
- name: Install dependencies
if ("${{ runner.os }}" STREQUAL "Windows")
set(ninja_suffix "win.zip")
set(cmake_suffix "windows-x86_64.zip")
set(cmake_dir "cmake-${cmake_version}-windows-x86_64/bin")
elseif ("${{ runner.os }}" STREQUAL "Linux")
set(ninja_suffix "linux.zip")
set(cmake_suffix "linux-x86_64.tar.gz")
set(cmake_dir "cmake-${cmake_version}-linux-x86_64/bin")
elseif ("${{ runner.os }}" STREQUAL "macOS")
set(ninja_suffix "mac.zip")
set(cmake_suffix "macos-universal.tar.gz")
set(cmake_dir "cmake-${cmake_version}-macos-universal/CMake.app/Contents/bin")
endif()
set(ninja_url "https://github.com/ninja-build/ninja/releases/download/v${ninja_version}/ninja-${ninja_suffix}")
file(DOWNLOAD "${ninja_url}" ./ninja.zip SHOW_PROGRESS)
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ./ninja.zip)
set(cmake_url "https://github.com/Kitware/CMake/releases/download/v${cmake_version}/cmake-${cmake_version}-${cmake_suffix}")
file(DOWNLOAD "${cmake_url}" ./cmake.zip SHOW_PROGRESS)
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ./cmake.zip)
# Add to PATH environment variable
file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/${cmake_dir}" cmake_dir)
set(path_separator ":")
if ("${{ runner.os }}" STREQUAL "Windows")
set(path_separator ";")
endif()
file(APPEND "$ENV{GITHUB_PATH}" "$ENV{GITHUB_WORKSPACE}${path_separator}${cmake_dir}")
if (NOT "${{ runner.os }}" STREQUAL "Windows")
execute_process(
COMMAND chmod +x ninja
COMMAND chmod +x ${cmake_dir}/cmake
)
endif()
- name: Install system libs
shell: cmake -P {0}
run: |
if ("${{ runner.os }}" STREQUAL "Linux")
@@ -88,13 +107,7 @@ jobs:
COMMAND sudo apt update
)
execute_process(
COMMAND sudo apt install
# build dependencies
libgl1-mesa-dev libgtest-dev libgmock-dev
# runtime dependencies for tests (Qt is downloaded outside package manager,
# thus minimal dependencies must be installed explicitly)
libsecret-1-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-render0
libxcb-shape0 libxcb-shm0 libxcb-sync1 libxcb-xfixes0 libxcb-xkb1 libxkbcommon-x11-0 xvfb
COMMAND sudo apt install libgl1-mesa-dev libcups2-dev
RESULT_VARIABLE result
)
if (NOT result EQUAL 0)
@@ -106,19 +119,14 @@ jobs:
id: qt
shell: cmake -P {0}
run: |
set(qt_version "${{ matrix.qt_config.qt_version }}")
set(qt_creator_version "${{ matrix.qt_config.qt_creator_version }}")
set(qt_version "$ENV{QT_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")
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()
set(qt_package_arch_suffix "win64_msvc2019_64")
set(qt_dir_prefix "${qt_version}/msvc2019_64")
set(qt_package_suffix "-Windows-Windows_10_22H2-MSVC2019-Windows-Windows_10_22H2-X86_64")
elseif ("${{ runner.os }}" STREQUAL "Linux")
set(url_os "linux_x64")
if (qt_version VERSION_LESS "6.7.0")
@@ -127,23 +135,15 @@ jobs:
set(qt_package_arch_suffix "linux_gcc_64")
endif()
set(qt_dir_prefix "${qt_version}/gcc_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()
set(qt_package_suffix "-Linux-RHEL_8_8-GCC-Linux-RHEL_8_8-X86_64")
elseif ("${{ runner.os }}" STREQUAL "macOS")
set(url_os "mac_x64")
set(qt_package_arch_suffix "clang_64")
set(qt_dir_prefix "${qt_version}/macos")
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()
set(qt_package_suffix "-MacOS-MacOS_13-Clang-MacOS-MacOS_13-X86_64-ARM64")
endif()
set(qt_base_url "https://download.qt.io/online/qtsdkrepository/${url_os}/desktop/qt6_${qt_version_dotless}/qt6_${qt_version_dotless}")
set(qt_base_url "https://download.qt.io/online/qtsdkrepository/${url_os}/desktop/qt6_${qt_version_dotless}")
file(DOWNLOAD "${qt_base_url}/Updates.xml" ./Updates.xml SHOW_PROGRESS)
file(READ ./Updates.xml updates_xml)
@@ -153,7 +153,7 @@ jobs:
file(MAKE_DIRECTORY qt6)
# Save the path for other steps
file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/qt6" qt_dir)
file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/qt6/${qt_dir_prefix}" qt_dir)
file(APPEND "$ENV{GITHUB_OUTPUT}" "qt_dir=${qt_dir}")
message("Downloading Qt to ${qt_dir}")
@@ -163,7 +163,7 @@ jobs:
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ../${archive} WORKING_DIRECTORY qt6)
endfunction()
foreach(package qtbase qtdeclarative qttools qtsvg)
foreach(package qtbase qtdeclarative)
downloadAndExtract(
"${qt_base_url}/qt.qt6.${qt_version_dotless}.${qt_package_arch_suffix}/${qt_package_version}${package}${qt_package_suffix}.7z"
${package}.7z
@@ -172,17 +172,11 @@ jobs:
foreach(package qt5compat qtshadertools)
downloadAndExtract(
"${qt_base_url}/qt.qt6.${qt_version_dotless}.addons.${package}.${qt_package_arch_suffix}/${qt_package_version}${package}${qt_package_suffix}.7z"
"${qt_base_url}/qt.qt6.${qt_version_dotless}.${package}.${qt_package_arch_suffix}/${qt_package_version}${package}${qt_package_suffix}.7z"
${package}.7z
)
endforeach()
function(downloadAndExtractLibicu url archive)
message("Downloading ${url}")
file(DOWNLOAD "${url}" ./${archive} SHOW_PROGRESS)
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ../../${archive} WORKING_DIRECTORY qt6/lib)
endfunction()
# uic depends on libicu*.so
if ("${{ runner.os }}" STREQUAL "Linux")
if (qt_version VERSION_LESS "6.7.0")
@@ -190,26 +184,47 @@ jobs:
else()
set(uic_suffix "Rhel8.6-x86_64")
endif()
downloadAndExtractLibicu(
downloadAndExtract(
"${qt_base_url}/qt.qt6.${qt_version_dotless}.${qt_package_arch_suffix}/${qt_package_version}icu-linux-${uic_suffix}.7z"
icu.7z
)
endif()
- name: Download Qt Creator
uses: qt-creator/install-dev-package@1460787a21551eb3d867b0de30e8d3f1aadef5ac
with:
version: ${{ matrix.qt_config.qt_creator_version }}
unzip-to: 'qtcreator'
platform: ${{ matrix.config.platform }}
- name: Extract Qt Creator
id: qt_creator
shell: cmake -P {0}
run: |
string(REGEX MATCH "([0-9]+.[0-9]+).[0-9]+" outvar "$ENV{QT_CREATOR_VERSION}")
set(qtc_base_url "https://download.qt.io/official_releases/qtcreator/${CMAKE_MATCH_1}/$ENV{QT_CREATOR_VERSION}/installer_source")
set(qtc_snapshot "$ENV{QT_CREATOR_SNAPSHOT}")
if (qtc_snapshot)
set(qtc_base_url "https://download.qt.io/snapshots/qtcreator/${CMAKE_MATCH_1}/$ENV{QT_CREATOR_VERSION}/installer_source/${qtc_snapshot}")
endif()
if ("${{ runner.os }}" STREQUAL "Windows")
set(qtc_platform "windows_x64")
elseif ("${{ runner.os }}" STREQUAL "Linux")
set(qtc_platform "linux_x64")
elseif ("${{ runner.os }}" STREQUAL "macOS")
set(qtc_platform "mac_x64")
endif()
file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/qtcreator" qtc_dir)
# Save the path for other steps
file(APPEND "$ENV{GITHUB_OUTPUT}" "qtc_dir=${qtc_dir}")
file(MAKE_DIRECTORY qtcreator)
message("Downloading Qt Creator from ${qtc_base_url}/${qtc_platform}")
foreach(package qtcreator qtcreator_dev)
file(DOWNLOAD
"${qtc_base_url}/${qtc_platform}/${package}.7z" ./${package}.7z SHOW_PROGRESS)
execute_process(COMMAND
${CMAKE_COMMAND} -E tar xvf ../${package}.7z WORKING_DIRECTORY qtcreator)
endforeach()
- name: Build
shell: cmake -P {0}
run: |
@@ -247,7 +262,7 @@ jobs:
COMMAND python
-u
"${{ steps.qt_creator.outputs.qtc_dir }}/${build_plugin_py}"
--name "$ENV{PLUGIN_NAME}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}"
--name "$ENV{PLUGIN_NAME}-$ENV{QT_CREATOR_VERSION}-${{ matrix.config.artifact }}"
--src .
--build build
--qt-path "${{ steps.qt.outputs.qt_dir }}"
@@ -263,24 +278,19 @@ jobs:
endif()
- name: Upload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
uses: actions/upload-artifact@v4
with:
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
path: ./${{ env.PLUGIN_NAME }}-${{ env.QT_CREATOR_VERSION }}-${{ matrix.config.artifact }}.7z
name: ${{ env.PLUGIN_NAME}}-${{ env.QT_CREATOR_VERSION }}-${{ matrix.config.artifact }}.7z
release:
if: contains(github.ref, 'tags/v')
runs-on: ubuntu-22.04
needs: [build]
runs-on: ubuntu-latest
needs: build
steps:
- name: Download artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
uses: actions/download-artifact@v4
with:
path: release-with-dirs
@@ -289,21 +299,9 @@ jobs:
mkdir release
mv release-with-dirs/*/* release/
- name: Download QodeAssistUpdater
run: |
# Get latest release info and download assets
LATEST_RELEASE=$(curl -s https://api.github.com/repos/Palm1r/QodeAssistUpdater/releases/latest)
# Download all assets except .sha256 files
echo "$LATEST_RELEASE" | jq -r '.assets[].browser_download_url' | grep -v '\.sha256$' | while read url; do
filename=$(basename "$url")
echo "Downloading $filename..."
curl -L -o "release/$filename" "$url"
done
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

8
.gitignore vendored
View File

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

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "sources/external/llmqore"]
path = sources/external/llmqore
url = https://github.com/Palm1r/llmqore.git

View File

@@ -8,43 +8,14 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
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 Svg Test LinguistTools REQUIRED)
find_package(GTest)
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network REQUIRED)
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})
set(QODEASSIST_QT_CREATOR_VERSION_MINOR ${CMAKE_MATCH_2})
set(QODEASSIST_QT_CREATOR_VERSION_PATCH ${CMAKE_MATCH_3})
if(NOT version_match)
message(FATAL_ERROR "Failed to parse Qt Creator version string: ${IDE_VERSION}")
endif()
message(STATUS "Qt Creator Version: ${QODEASSIST_QT_CREATOR_VERSION_MAJOR}.${QODEASSIST_QT_CREATOR_VERSION_MINOR}.${QODEASSIST_QT_CREATOR_VERSION_PATCH}")
add_definitions(
-DQODEASSIST_QT_CREATOR_VERSION_MAJOR=${QODEASSIST_QT_CREATOR_VERSION_MAJOR}
-DQODEASSIST_QT_CREATOR_VERSION_MINOR=${QODEASSIST_QT_CREATOR_VERSION_MINOR}
-DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
)
add_subdirectory(sources/external/llmqore)
add_subdirectory(sources/skills)
add_subdirectory(pluginllmcore)
add_subdirectory(llmcore)
add_subdirectory(settings)
add_subdirectory(logger)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
add_subdirectory(context)
if(GTest_FOUND)
add_subdirectory(test)
endif()
add_qtc_plugin(QodeAssist
PLUGIN_DEPENDS
@@ -52,20 +23,14 @@ add_qtc_plugin(QodeAssist
QtCreator::LanguageClient
QtCreator::TextEditor
QtCreator::ProjectExplorer
QtCreator::CppEditor
DEPENDS
Qt::Core
Qt::Gui
Qt::Quick
Qt::Widgets
Qt::Network
Qt::Svg
QtCreator::ExtensionSystem
QtCreator::Utils
QtCreator::CPlusPlus
LLMQore
PluginLLMCore
Skills
QodeAssistChatViewplugin
SOURCES
.github/workflows/build_cmake.yml
@@ -75,111 +40,33 @@ add_qtc_plugin(QodeAssist
QodeAssistConstants.hpp
QodeAssisttr.h
LLMClientInterface.hpp LLMClientInterface.cpp
RefactorContextHelper.hpp
templates/Templates.hpp
templates/CodeLlamaFim.hpp
templates/Ollama.hpp
templates/Claude.hpp
templates/OpenAI.hpp
templates/MistralAI.hpp
templates/StarCoder2Fim.hpp
templates/Qwen25CoderFIM.hpp
templates/OpenAICompatible.hpp
templates/DeepSeekCoderFim.hpp
templates/CustomFimTemplate.hpp
templates/Qwen.hpp
templates/Ollama.hpp
templates/BasicChat.hpp
templates/Llama3.hpp
templates/ChatML.hpp
templates/Alpaca.hpp
templates/Llama2.hpp
templates/CodeLlamaQMLFim.hpp
templates/GoogleAI.hpp
templates/LlamaCppFim.hpp
templates/Qwen3CoderFIM.hpp
templates/OpenAIResponses.hpp
providers/Providers.hpp
providers/ProviderUrlUtils.hpp
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
providers/OllamaCompatProvider.hpp providers/OllamaCompatProvider.cpp
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
providers/OpenAIProvider.hpp providers/OpenAIProvider.cpp
providers/MistralAIProvider.hpp providers/MistralAIProvider.cpp
providers/LMStudioProvider.hpp providers/LMStudioProvider.cpp
providers/LMStudioResponsesProvider.hpp providers/LMStudioResponsesProvider.cpp
providers/OpenAICompatProvider.hpp providers/OpenAICompatProvider.cpp
providers/OpenRouterAIProvider.hpp providers/OpenRouterAIProvider.cpp
providers/GoogleAIProvider.hpp providers/GoogleAIProvider.cpp
providers/LlamaCppProvider.hpp providers/LlamaCppProvider.cpp
providers/CodestralProvider.hpp providers/CodestralProvider.cpp
providers/OpenAIResponsesProvider.hpp providers/OpenAIResponsesProvider.cpp
QodeAssist.qrc
LSPCompletion.hpp
LLMSuggestion.hpp LLMSuggestion.cpp
RefactorSuggestion.hpp RefactorSuggestion.cpp
RefactorSuggestionHoverHandler.hpp RefactorSuggestionHoverHandler.cpp
QodeAssistClient.hpp QodeAssistClient.cpp
DocumentContextReader.hpp DocumentContextReader.cpp
utils/CounterTooltip.hpp utils/CounterTooltip.cpp
core/ChangesManager.h core/ChangesManager.cpp
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
chat/ChatDocument.hpp chat/ChatDocument.cpp
chat/ChatEditor.hpp chat/ChatEditor.cpp
chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp
ConfigurationManager.hpp ConfigurationManager.cpp
CodeHandler.hpp CodeHandler.cpp
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
widgets/CompletionProgressHandler.hpp widgets/CompletionProgressHandler.cpp
widgets/CompletionErrorHandler.hpp widgets/CompletionErrorHandler.cpp
widgets/CompletionHintWidget.hpp widgets/CompletionHintWidget.cpp
widgets/CompletionHintHandler.hpp widgets/CompletionHintHandler.cpp
widgets/ProgressWidget.hpp widgets/ProgressWidget.cpp
widgets/ErrorWidget.hpp widgets/ErrorWidget.cpp
widgets/EditorChatButton.hpp widgets/EditorChatButton.cpp
widgets/EditorChatButtonHandler.hpp widgets/EditorChatButtonHandler.cpp
widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp
widgets/CustomInstructionsManager.hpp widgets/CustomInstructionsManager.cpp
widgets/AddCustomInstructionDialog.hpp widgets/AddCustomInstructionDialog.cpp
widgets/RefactorWidget.hpp widgets/RefactorWidget.cpp
widgets/RefactorWidgetHandler.hpp widgets/RefactorWidgetHandler.cpp
widgets/ContextExtractor.hpp
widgets/DiffStatistics.hpp
QuickRefactorHandler.hpp QuickRefactorHandler.cpp
tools/ToolsRegistration.hpp tools/ToolsRegistration.cpp
tools/ListProjectFilesTool.hpp tools/ListProjectFilesTool.cpp
tools/GetIssuesListTool.hpp tools/GetIssuesListTool.cpp
tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp
tools/EditFileTool.hpp tools/EditFileTool.cpp
tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp
tools/ExecuteTerminalCommandTool.hpp tools/ExecuteTerminalCommandTool.cpp
tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp
tools/FindFileTool.hpp tools/FindFileTool.cpp
tools/ReadFileTool.hpp tools/ReadFileTool.cpp
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
tools/TodoTool.hpp tools/TodoTool.cpp
tools/ReadOriginalHistoryTool.hpp tools/ReadOriginalHistoryTool.cpp
tools/SkillTool.hpp tools/SkillTool.cpp
mcp/McpServerManager.hpp mcp/McpServerManager.cpp
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
)
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
find_program(QtCreatorExecutable
NAMES
qtcreator "Qt Creator"
PATHS
"${QtCreatorCorePath}/../../../bin"
"${QtCreatorCorePath}/../../../MacOS"
NO_DEFAULT_PATH
)
if (QtCreatorExecutable)
add_custom_target(RunQtCreator
COMMAND ${QtCreatorExecutable} -pluginpath $<TARGET_FILE_DIR:QodeAssist>
DEPENDS QodeAssist
)
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

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

View File

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

View File

@@ -1,64 +1,18 @@
qt_add_library(QodeAssistChatView STATIC)
qt_policy(SET QTP0001 NEW)
qt_policy(SET QTP0004 NEW)
# URI name should match the subdirectory name to suppress the warning
qt_add_qml_module(QodeAssistChatView
URI ChatView
VERSION 1.0
DEPENDENCIES
QtQuick
DEPENDENCIES QtQuick
QML_FILES
qml/RootItem.qml
qml/chatparts/CodeBlock.qml
qml/chatparts/FileEditBlock.qml
qml/chatparts/TextBlock.qml
qml/chatparts/ThinkingBlock.qml
qml/chatparts/ToolBlock.qml
qml/chatparts/ChatItem.qml
qml/controls/AttachedFilesPlace.qml
qml/controls/BottomBar.qml
qml/controls/FileMentionPopup.qml
qml/controls/FileEditsActionBar.qml
qml/controls/ContextViewer.qml
qml/controls/SkillCommandPopup.qml
qml/controls/Toast.qml
qml/controls/TopBar.qml
qml/controls/SplitDropZone.qml
RESOURCES
icons/attach-file-light.svg
icons/attach-file-dark.svg
icons/close-dark.svg
icons/close-light.svg
icons/link-file-light.svg
icons/link-file-dark.svg
icons/image-dark.svg
icons/load-chat-dark.svg
icons/save-chat-dark.svg
icons/clean-icon-dark.svg
icons/file-in-system.svg
icons/window-lock.svg
icons/window-unlock.svg
icons/chat-icon.svg
icons/chat-pause-icon.svg
icons/new-chat-icon.svg
icons/rules-icon.svg
icons/context-icon.svg
icons/open-in-editor.svg
icons/open-in-window.svg
icons/apply-changes-button.svg
icons/undo-changes-button.svg
icons/reject-changes-button.svg
icons/thinking-icon-on.svg
icons/thinking-icon-off.svg
icons/tools-icon-on.svg
icons/tools-icon-off.svg
icons/settings-icon.svg
icons/compress-icon.svg
qml/ChatItem.qml
qml/Badge.qml
qml/dialog/CodeBlock.qml
qml/dialog/TextBlock.qml
SOURCES
ChatWidget.hpp ChatWidget.cpp
ChatModel.hpp ChatModel.cpp
@@ -66,19 +20,6 @@ qt_add_qml_module(QodeAssistChatView
ClientInterface.hpp ClientInterface.cpp
MessagePart.hpp
ChatUtils.h ChatUtils.cpp
ChatSerializer.hpp ChatSerializer.cpp
ChatView.hpp ChatView.cpp
ChatData.hpp
FileItem.hpp FileItem.cpp
ChatFileManager.hpp ChatFileManager.cpp
ChatCompressor.hpp ChatCompressor.cpp
AgentRoleController.hpp AgentRoleController.cpp
ChatConfigurationController.hpp ChatConfigurationController.cpp
FileEditController.hpp FileEditController.cpp
InputTokenCounter.hpp InputTokenCounter.cpp
ChatHistoryStore.hpp ChatHistoryStore.cpp
FileMentionItem.hpp FileMentionItem.cpp
SessionFileRegistry.hpp SessionFileRegistry.cpp
)
target_link_libraries(QodeAssistChatView
@@ -89,15 +30,10 @@ target_link_libraries(QodeAssistChatView
Qt::Network
QtCreator::Core
QtCreator::Utils
PluginLLMCore
LLMCore
QodeAssistSettings
Context
QodeAssistUIControlsplugin
QodeAssistLogger
LLMQore
Skills
)
target_include_directories(QodeAssistChatView
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -1,292 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatCompressor.hpp"
#include <LLMQore/BaseClient.hpp>
#include "ChatModel.hpp"
#include "GeneralSettings.hpp"
#include "PromptTemplateManager.hpp"
#include "ProvidersManager.hpp"
#include "logger/Logger.hpp"
#include <QDateTime>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QUuid>
namespace QodeAssist::Chat {
ChatCompressor::ChatCompressor(QObject *parent)
: QObject(parent)
{}
void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *chatModel)
{
if (m_isCompressing) {
emit compressionFailed(tr("Compression already in progress"));
return;
}
if (chatFilePath.isEmpty()) {
emit compressionFailed(tr("No chat file to compress"));
return;
}
if (!chatModel || chatModel->rowCount() == 0) {
emit compressionFailed(tr("Chat is empty, nothing to compress"));
return;
}
auto providerName = Settings::generalSettings().caProvider();
m_provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName);
if (!m_provider) {
emit compressionFailed(tr("No provider available"));
return;
}
auto templateName = Settings::generalSettings().caTemplate();
auto promptTemplate = PluginLLMCore::PromptTemplateManager::instance().getChatTemplateByName(
templateName);
if (!promptTemplate) {
emit compressionFailed(tr("No template available"));
return;
}
m_isCompressing = true;
m_chatModel = chatModel;
m_originalChatPath = chatFilePath;
m_accumulatedSummary.clear();
emit compressionStarted();
connectProviderSignals();
QJsonObject payload{
{"model", Settings::generalSettings().caModel()}, {"stream", true}};
buildRequestPayload(payload, promptTemplate);
const QString customEndpoint = Settings::generalSettings().caCustomEndpoint();
const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint
: promptTemplate->endpoint();
m_currentRequestId = m_provider->sendRequest(
QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
LOG_MESSAGE(QString("Starting compression request: %1").arg(m_currentRequestId));
}
bool ChatCompressor::isCompressing() const
{
return m_isCompressing;
}
void ChatCompressor::cancelCompression()
{
if (!m_isCompressing)
return;
LOG_MESSAGE("Cancelling compression request");
if (m_provider && !m_currentRequestId.isEmpty())
m_provider->cancelRequest(m_currentRequestId);
cleanupState();
emit compressionFailed(tr("Compression cancelled"));
}
void ChatCompressor::onPartialResponseReceived(const QString &requestId, const QString &partialText)
{
if (!m_isCompressing || requestId != m_currentRequestId)
return;
m_accumulatedSummary += partialText;
}
void ChatCompressor::onFullResponseReceived(const QString &requestId, const QString &fullText)
{
Q_UNUSED(fullText)
if (!m_isCompressing || requestId != m_currentRequestId)
return;
LOG_MESSAGE(
QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length()));
QString compressedPath = createCompressedChatPath(m_originalChatPath);
if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) {
handleCompressionError(tr("Failed to save compressed chat"));
return;
}
LOG_MESSAGE(QString("Compression completed: %1").arg(compressedPath));
cleanupState();
emit compressionCompleted(compressedPath);
}
void ChatCompressor::onRequestFailed(const QString &requestId, const QString &error)
{
if (!m_isCompressing || requestId != m_currentRequestId)
return;
LOG_MESSAGE(QString("Compression request failed: %1").arg(error));
handleCompressionError(tr("Compression failed: %1").arg(error));
}
void ChatCompressor::handleCompressionError(const QString &error)
{
cleanupState();
emit compressionFailed(error);
}
QString ChatCompressor::createCompressedChatPath(const QString &originalPath) const
{
QFileInfo fileInfo(originalPath);
QString hash = QString::number(QDateTime::currentMSecsSinceEpoch() % 100000, 16);
return QString("%1/%2_%3.%4")
.arg(fileInfo.absolutePath(), fileInfo.completeBaseName(), hash, fileInfo.suffix());
}
QString ChatCompressor::buildCompressionPrompt() const
{
return QStringLiteral(
"Please create a comprehensive summary of our entire conversation above. "
"The summary should:\n"
"1. Preserve all important context, decisions, and key information\n"
"2. Maintain technical details, code snippets, file references, and specific examples\n"
"3. Keep the chronological flow of the discussion\n"
"4. Be significantly shorter than the original (aim for 30-40% of original length)\n"
"5. Be written in clear, structured format\n"
"6. Use markdown formatting for better readability\n\n"
"Create the summary now:");
}
void ChatCompressor::buildRequestPayload(
QJsonObject &payload, PluginLLMCore::PromptTemplate *promptTemplate)
{
PluginLLMCore::ContextData context;
context.systemPrompt = QStringLiteral(
"You are a helpful assistant that creates concise summaries of conversations. "
"Your summaries preserve key information, technical details, and the flow of discussion.");
QVector<PluginLLMCore::Message> messages;
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool
|| msg.role == ChatModel::ChatRole::FileEdit
|| msg.role == ChatModel::ChatRole::Thinking)
continue;
PluginLLMCore::Message apiMessage;
apiMessage.role = (msg.role == ChatModel::ChatRole::User) ? "user" : "assistant";
apiMessage.content = msg.content;
messages.append(apiMessage);
}
PluginLLMCore::Message compressionRequest;
compressionRequest.role = "user";
compressionRequest.content = buildCompressionPrompt();
messages.append(compressionRequest);
context.history = messages;
m_provider->prepareRequest(
payload, promptTemplate, context, PluginLLMCore::RequestType::Chat, false, false);
}
bool ChatCompressor::createCompressedChatFile(
const QString &sourcePath, const QString &destPath, const QString &summary)
{
QFile sourceFile(sourcePath);
if (!sourceFile.open(QIODevice::ReadOnly)) {
LOG_MESSAGE(QString("Failed to open source chat file: %1").arg(sourcePath));
return false;
}
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(sourceFile.readAll(), &parseError);
sourceFile.close();
if (doc.isNull() || !doc.isObject()) {
LOG_MESSAGE(QString("Invalid JSON in chat file: %1 (Error: %2)")
.arg(sourcePath, parseError.errorString()));
return false;
}
QJsonObject root = doc.object();
QJsonObject summaryMessage;
summaryMessage["role"] = "assistant";
summaryMessage["content"] = QString("# Chat Summary\n\n%1").arg(summary);
summaryMessage["id"] = QUuid::createUuid().toString(QUuid::WithoutBraces);
summaryMessage["isRedacted"] = false;
summaryMessage["attachments"] = QJsonArray();
summaryMessage["images"] = QJsonArray();
root["messages"] = QJsonArray{summaryMessage};
root["compressedFrom"] = sourcePath;
root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate);
if (QFile::exists(destPath))
QFile::remove(destPath);
QFile destFile(destPath);
if (!destFile.open(QIODevice::WriteOnly)) {
LOG_MESSAGE(QString("Failed to create compressed chat file: %1").arg(destPath));
return false;
}
destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
return true;
}
void ChatCompressor::connectProviderSignals()
{
auto *c = m_provider->client();
m_connections.append(connect(
c,
&::LLMQore::BaseClient::chunkReceived,
this,
&ChatCompressor::onPartialResponseReceived,
Qt::UniqueConnection));
m_connections.append(connect(
c,
&::LLMQore::BaseClient::requestCompleted,
this,
&ChatCompressor::onFullResponseReceived,
Qt::UniqueConnection));
m_connections.append(connect(
c,
&::LLMQore::BaseClient::requestFailed,
this,
&ChatCompressor::onRequestFailed,
Qt::UniqueConnection));
}
void ChatCompressor::disconnectAllSignals()
{
for (const auto &connection : std::as_const(m_connections))
disconnect(connection);
m_connections.clear();
}
void ChatCompressor::cleanupState()
{
disconnectAllSignals();
m_isCompressing = false;
m_currentRequestId.clear();
m_originalChatPath.clear();
m_accumulatedSummary.clear();
m_chatModel = nullptr;
m_provider = nullptr;
}
} // namespace QodeAssist::Chat

View File

@@ -1,63 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonObject>
#include <QList>
#include <QObject>
#include <QString>
namespace QodeAssist::PluginLLMCore {
class Provider;
class PromptTemplate;
} // namespace QodeAssist::PluginLLMCore
namespace QodeAssist::Chat {
class ChatModel;
class ChatCompressor : public QObject
{
Q_OBJECT
public:
explicit ChatCompressor(QObject *parent = nullptr);
void startCompression(const QString &chatFilePath, ChatModel *chatModel);
bool isCompressing() const;
void cancelCompression();
signals:
void compressionStarted();
void compressionCompleted(const QString &compressedChatPath);
void compressionFailed(const QString &error);
private slots:
void onPartialResponseReceived(const QString &requestId, const QString &partialText);
void onFullResponseReceived(const QString &requestId, const QString &fullText);
void onRequestFailed(const QString &requestId, const QString &error);
private:
QString createCompressedChatPath(const QString &originalPath) const;
QString buildCompressionPrompt() const;
bool createCompressedChatFile(
const QString &sourcePath, const QString &destPath, const QString &summary);
void connectProviderSignals();
void disconnectAllSignals();
void cleanupState();
void handleCompressionError(const QString &error);
void buildRequestPayload(QJsonObject &payload, PluginLLMCore::PromptTemplate *promptTemplate);
bool m_isCompressing = false;
QString m_currentRequestId;
QString m_originalChatPath;
QString m_accumulatedSummary;
PluginLLMCore::Provider *m_provider = nullptr;
ChatModel *m_chatModel = nullptr;
QList<QMetaObject::Connection> m_connections;
};
} // namespace QodeAssist::Chat

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QtQmlIntegration>
namespace QodeAssist::Chat {
Q_NAMESPACE
QML_NAMED_ELEMENT(MessagePartType)
enum class MessagePartType { Code, Text, Image };
Q_ENUM_NS(MessagePartType)
} // namespace QodeAssist::Chat

View File

@@ -1,190 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatFileManager.hpp"
#include "Logger.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include <QUuid>
#include <QDateTime>
#include <QRegularExpression>
#include <coreplugin/icore.h>
namespace QodeAssist::Chat {
ChatFileManager::ChatFileManager(QObject *parent)
: QObject(parent)
, m_intermediateStorageDir(getIntermediateStorageDir())
{}
ChatFileManager::~ChatFileManager() = default;
QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
{
QStringList processedPaths;
processedPaths.reserve(filePaths.size());
for (const QString &filePath : filePaths) {
if (!isFileAccessible(filePath)) {
const QString error = tr("File is not accessible: %1").arg(filePath);
LOG_MESSAGE(error);
emit fileOperationFailed(error);
continue;
}
QString copiedPath = copyToIntermediateStorage(filePath);
if (!copiedPath.isEmpty()) {
processedPaths.append(copiedPath);
emit fileCopiedToStorage(filePath, copiedPath);
LOG_MESSAGE(QString("File copied to storage: %1 -> %2").arg(filePath, copiedPath));
} else {
const QString error = tr("Failed to copy file: %1").arg(filePath);
LOG_MESSAGE(error);
emit fileOperationFailed(error);
}
}
return processedPaths;
}
void ChatFileManager::setChatFilePath(const QString &chatFilePath)
{
m_chatFilePath = chatFilePath;
}
QString ChatFileManager::chatFilePath() const
{
return m_chatFilePath;
}
void ChatFileManager::clearIntermediateStorage()
{
QDir dir(m_intermediateStorageDir);
if (!dir.exists()) {
return;
}
const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
for (const QFileInfo &fileInfo : files) {
QFile file(fileInfo.absoluteFilePath());
file.setPermissions(QFile::WriteUser | QFile::ReadUser);
if (file.remove()) {
LOG_MESSAGE(QString("Removed intermediate file: %1").arg(fileInfo.fileName()));
} else {
LOG_MESSAGE(QString("Failed to remove intermediate file: %1")
.arg(fileInfo.fileName()));
}
}
}
bool ChatFileManager::isFileAccessible(const QString &filePath)
{
QFileInfo fileInfo(filePath);
return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable();
}
void ChatFileManager::cleanupGlobalIntermediateStorage()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
QDir dir(intermediatePath);
if (!dir.exists()) {
return;
}
const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
int removedCount = 0;
int failedCount = 0;
for (const QFileInfo &fileInfo : files) {
QFile file(fileInfo.absoluteFilePath());
file.setPermissions(QFile::WriteUser | QFile::ReadUser);
if (file.remove()) {
removedCount++;
} else {
failedCount++;
}
}
if (removedCount > 0 || failedCount > 0) {
LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2")
.arg(removedCount)
.arg(failedCount));
}
}
QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
{
QFileInfo fileInfo(filePath);
if (!fileInfo.exists() || !fileInfo.isFile()) {
LOG_MESSAGE(QString("Source file does not exist or is not a file: %1").arg(filePath));
return QString();
}
if (fileInfo.size() == 0) {
LOG_MESSAGE(QString("Source file is empty: %1").arg(filePath));
}
const QString newFileName = generateIntermediateFileName(filePath);
const QString destinationPath = QDir(m_intermediateStorageDir).filePath(newFileName);
if (QFileInfo::exists(destinationPath)) {
QFile::remove(destinationPath);
}
if (!QFile::copy(filePath, destinationPath)) {
LOG_MESSAGE(QString("Failed to copy file: %1 -> %2").arg(filePath, destinationPath));
return QString();
}
QFile copiedFile(destinationPath);
if (!copiedFile.exists()) {
LOG_MESSAGE(QString("Copied file does not exist after copy: %1").arg(destinationPath));
return QString();
}
copiedFile.setPermissions(QFile::ReadUser | QFile::WriteUser);
return destinationPath;
}
QString ChatFileManager::getIntermediateStorageDir()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
QDir dir;
if (!dir.exists(intermediatePath) && !dir.mkpath(intermediatePath)) {
LOG_MESSAGE(QString("Failed to create intermediate storage directory: %1")
.arg(intermediatePath));
}
return intermediatePath;
}
QString ChatFileManager::generateIntermediateFileName(const QString &originalPath)
{
const QFileInfo fileInfo(originalPath);
const QString extension = fileInfo.suffix();
QString baseName = fileInfo.completeBaseName().left(30);
static const QRegularExpression specialChars("[^a-zA-Z0-9_-]");
baseName.replace(specialChars, "_");
if (baseName.isEmpty()) {
baseName = "file";
}
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8);
return QString("%1_%2_%3.%4").arg(baseName, timestamp, uuid, extension);
}
} // namespace QodeAssist::Chat

View File

@@ -1,43 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QMap>
namespace QodeAssist::Chat {
class ChatFileManager : public QObject
{
Q_OBJECT
public:
explicit ChatFileManager(QObject *parent = nullptr);
~ChatFileManager();
QStringList processDroppedFiles(const QStringList &filePaths);
void setChatFilePath(const QString &chatFilePath);
QString chatFilePath() const;
void clearIntermediateStorage();
static bool isFileAccessible(const QString &filePath);
static void cleanupGlobalIntermediateStorage();
signals:
void fileOperationFailed(const QString &error);
void fileCopiedToStorage(const QString &originalPath, const QString &newPath);
private:
QString copyToIntermediateStorage(const QString &filePath);
QString getIntermediateStorageDir();
QString generateIntermediateFileName(const QString &originalPath);
QString m_chatFilePath;
QString m_intermediateStorageDir;
};
} // namespace QodeAssist::Chat

View File

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

View File

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

View File

@@ -1,38 +1,41 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 "ChatModel.hpp"
#include <utils/aspects.h>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QUrl>
#include <QtCore/qjsonobject.h>
#include <QtQml>
#include <utils/aspects.h>
#include "Logger.hpp"
#include "context/ChangesManager.h"
#include "ChatAssistantSettings.hpp"
namespace QodeAssist::Chat {
ChatModel::ChatModel(QObject *parent)
: QAbstractListModel(parent)
, m_totalTokens(0)
{
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditApplied,
auto &settings = Settings::chatAssistantSettings();
connect(&settings.chatTokensThreshold,
&Utils::BaseAspect::changed,
this,
&ChatModel::onFileEditApplied);
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditRejected,
this,
&ChatModel::onFileEditRejected);
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditArchived,
this,
&ChatModel::onFileEditArchived);
&ChatModel::tokensThresholdChanged);
}
int ChatModel::rowCount(const QModelIndex &parent) const
@@ -52,66 +55,6 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
case Roles::Content: {
return message.content;
}
case Roles::Attachments: {
QVariantList attachmentsList;
for (const auto &attachment : message.attachments) {
QVariantMap attachmentMap;
attachmentMap["fileName"] = attachment.filename;
attachmentMap["storedPath"] = attachment.content;
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(attachment.content);
attachmentMap["filePath"] = fullPath;
} else {
attachmentMap["filePath"] = QString();
}
attachmentsList.append(attachmentMap);
}
return attachmentsList;
}
case Roles::IsRedacted: {
return message.isRedacted;
}
case Roles::PromptTokens:
return message.promptTokens;
case Roles::CompletionTokens:
return message.completionTokens;
case Roles::CachedPromptTokens:
return message.cachedPromptTokens;
case Roles::ReasoningTokens:
return message.reasoningTokens;
case Roles::TotalTokens:
return message.promptTokens + message.completionTokens;
case Roles::Images: {
QVariantList imagesList;
for (const auto &image : message.images) {
QVariantMap imageMap;
imageMap["fileName"] = image.fileName;
imageMap["storedPath"] = image.storedPath;
imageMap["mediaType"] = image.mediaType;
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(image.storedPath);
imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString();
imageMap["filePath"] = fullPath;
} else {
imageMap["imageUrl"] = QString();
imageMap["filePath"] = QString();
}
imagesList.append(imageMap);
}
return imagesList;
}
default:
return QVariant();
}
@@ -122,84 +65,29 @@ QHash<int, QByteArray> ChatModel::roleNames() const
QHash<int, QByteArray> roles;
roles[Roles::RoleType] = "roleType";
roles[Roles::Content] = "content";
roles[Roles::Attachments] = "attachments";
roles[Roles::IsRedacted] = "isRedacted";
roles[Roles::Images] = "images";
roles[Roles::PromptTokens] = "promptTokens";
roles[Roles::CompletionTokens] = "completionTokens";
roles[Roles::CachedPromptTokens] = "cachedPromptTokens";
roles[Roles::ReasoningTokens] = "reasoningTokens";
roles[Roles::TotalTokens] = "totalTokens";
return roles;
}
void ChatModel::addMessage(
const QString &content,
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments,
const QList<ImageAttachment> &images,
bool isRedacted,
const QString &signature)
void ChatModel::addMessage(const QString &content, ChatRole role, const QString &id)
{
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id
&& m_messages.last().role == role) {
int tokenCount = estimateTokenCount(content);
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id) {
Message &lastMessage = m_messages.last();
int oldTokenCount = lastMessage.tokenCount;
lastMessage.content = content;
lastMessage.attachments = attachments;
lastMessage.images = images;
lastMessage.isRedacted = isRedacted;
lastMessage.signature = signature;
lastMessage.tokenCount = tokenCount;
m_totalTokens += (tokenCount - oldTokenCount);
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
} else {
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message newMessage{role, content, id};
newMessage.attachments = attachments;
newMessage.images = images;
newMessage.isRedacted = isRedacted;
newMessage.signature = signature;
m_messages.append(newMessage);
m_messages.append({role, content, tokenCount, id});
m_totalTokens += tokenCount;
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));
}
}
}
}
}
}
trim();
emit totalTokensChanged();
}
QVector<ChatModel::Message> ChatModel::getChatHistory() const
@@ -207,13 +95,32 @@ QVector<ChatModel::Message> ChatModel::getChatHistory() const
return m_messages;
}
void ChatModel::trim()
{
while (m_totalTokens > tokensThreshold()) {
if (!m_messages.isEmpty()) {
m_totalTokens -= m_messages.first().tokenCount;
beginRemoveRows(QModelIndex(), 0, 0);
m_messages.removeFirst();
endRemoveRows();
} else {
break;
}
}
}
int ChatModel::estimateTokenCount(const QString &text) const
{
return text.length() / 4;
}
void ChatModel::clear()
{
beginResetModel();
m_messages.clear();
m_totalTokens = 0;
endResetModel();
emit modelReseted();
emit sessionUsageChanged();
emit totalTokensChanged();
}
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
@@ -226,50 +133,19 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
while (blockMatches.hasNext()) {
auto match = blockMatches.next();
if (match.capturedStart() > lastIndex) {
QString textBetween
= content.mid(lastIndex, match.capturedStart() - lastIndex).trimmed();
QString textBetween = content.mid(lastIndex, match.capturedStart() - lastIndex).trimmed();
if (!textBetween.isEmpty()) {
MessagePart part;
part.type = MessagePartType::Text;
part.text = textBetween;
parts.append(part);
parts.append({MessagePart::Text, textBetween, ""});
}
}
MessagePart codePart;
codePart.type = MessagePartType::Code;
codePart.text = match.captured(2).trimmed();
codePart.language = match.captured(1);
parts.append(codePart);
parts.append({MessagePart::Code, match.captured(2).trimmed(), match.captured(1)});
lastIndex = match.capturedEnd();
}
if (lastIndex < content.length()) {
QString remainingText = content.mid(lastIndex).trimmed();
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);
if (!remainingText.isEmpty()) {
parts.append({MessagePart::Text, remainingText, ""});
}
}
@@ -279,6 +155,7 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) const
{
QJsonArray messages;
messages.append(QJsonObject{{"role", "system"}, {"content", systemPrompt}});
for (const auto &message : m_messages) {
@@ -290,395 +167,29 @@ QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) con
case ChatRole::Assistant:
role = "assistant";
break;
case ChatRole::Tool:
case ChatRole::FileEdit:
continue;
default:
continue;
}
QString content
= message.attachments.isEmpty()
? message.content
: message.content + "\n\nAttached files list:"
+ std::accumulate(
message.attachments.begin(),
message.attachments.end(),
QString(),
[](QString acc, const Context::ContentFile &attachment) {
return acc
+ QString("\nname: %1\nfile content:\n%2")
.arg(attachment.filename, attachment.content);
});
messages.append(QJsonObject{{"role", role}, {"content", content}});
messages.append(QJsonObject{{"role", role}, {"content", message.content}});
}
return messages;
}
int ChatModel::totalTokens() const
{
return m_totalTokens;
}
int ChatModel::tokensThreshold() const
{
auto &settings = Settings::chatAssistantSettings();
return settings.chatTokensThreshold();
}
QString ChatModel::lastMessageId() const
{
return !m_messages.isEmpty() ? m_messages.last().id : "";
}
void ChatModel::resetModelTo(int index)
{
if (index < 0 || index >= m_messages.size())
return;
if (index < m_messages.size()) {
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
m_messages.remove(index, m_messages.size() - index);
endRemoveRows();
emit sessionUsageChanged();
}
}
void ChatModel::addToolExecutionStatus(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments)
{
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;
lastMessage.toolName = toolName;
lastMessage.toolArguments = toolArguments;
LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1));
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
} else {
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message newMessage{ChatRole::Tool, content, toolId};
newMessage.toolName = toolName;
newMessage.toolArguments = toolArguments;
m_messages.append(newMessage);
endInsertRows();
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
.arg(m_messages.size() - 1)
.arg(toolId));
}
}
void ChatModel::dropTrailingAssistantMessage(const QString &requestId)
{
if (m_messages.isEmpty())
return;
const Message &last = m_messages.last();
if (last.role != ChatRole::Assistant || last.id != requestId)
return;
const int idx = m_messages.size() - 1;
beginRemoveRows(QModelIndex(), idx, idx);
m_messages.removeLast();
endRemoveRows();
LOG_MESSAGE(QString("Dropped leaked pre-tool assistant message at index %1").arg(idx));
}
void ChatModel::setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) {
m_messages[i].toolName = toolName;
m_messages[i].toolArguments = toolArguments;
m_messages[i].toolResult = toolResult;
return;
}
}
}
void ChatModel::updateToolResult(
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
{
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;
m_messages[i].toolName = toolName;
m_messages[i].toolResult = result;
emit dataChanged(index(i), index(i));
toolMessageFound = true;
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
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::addThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature)
{
LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3")
.arg(requestId)
.arg(thinking.length())
.arg(signature.length()));
QString displayContent = thinking;
if (!signature.isEmpty()) {
displayContent += "\n[Signature: " + signature.left(40) + "...]";
}
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) {
m_messages[i].content = displayContent;
m_messages[i].signature = signature;
emit dataChanged(index(i), index(i));
LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i));
return;
}
}
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message thinkingMessage;
thinkingMessage.role = ChatRole::Thinking;
thinkingMessage.content = displayContent;
thinkingMessage.id = requestId;
thinkingMessage.isRedacted = false;
thinkingMessage.signature = signature;
m_messages.append(thinkingMessage);
endInsertRows();
LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2")
.arg(m_messages.size() - 1).arg(signature.length()));
}
void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature)
{
LOG_MESSAGE(
QString("Adding redacted thinking block: requestId=%1, signature length=%2")
.arg(requestId)
.arg(signature.length()));
QString displayContent = "[Thinking content redacted by safety systems]";
if (!signature.isEmpty()) {
displayContent += "\n[Signature: " + signature.left(40) + "...]";
}
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message thinkingMessage;
thinkingMessage.role = ChatRole::Thinking;
thinkingMessage.content = displayContent;
thinkingMessage.id = requestId;
thinkingMessage.isRedacted = true;
thinkingMessage.signature = signature;
m_messages.append(thinkingMessage);
endInsertRows();
LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2")
.arg(m_messages.size() - 1).arg(signature.length()));
}
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::setMessageUsage(
const QString &messageId,
int promptTokens,
int completionTokens,
int cachedPromptTokens,
int reasoningTokens)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].id != messageId)
continue;
m_messages[i].promptTokens = promptTokens;
m_messages[i].completionTokens = completionTokens;
m_messages[i].cachedPromptTokens = cachedPromptTokens;
m_messages[i].reasoningTokens = reasoningTokens;
emit dataChanged(
index(i),
index(i),
{Roles::PromptTokens,
Roles::CompletionTokens,
Roles::CachedPromptTokens,
Roles::ReasoningTokens,
Roles::TotalTokens});
emit sessionUsageChanged();
return;
}
}
int ChatModel::sessionPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.promptTokens;
return total;
}
int ChatModel::sessionCompletionTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.completionTokens;
return total;
}
int ChatModel::sessionCachedPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.cachedPromptTokens;
return total;
}
int ChatModel::sessionTotalTokens() const
{
return sessionPromptTokens() + sessionCompletionTokens();
}
void ChatModel::setLoadingFromHistory(bool loading)
{
m_loadingFromHistory = loading;
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;
}
}
}
}
}
}
void ChatModel::setChatFilePath(const QString &filePath)
{
m_chatFilePath = filePath;
}
QString ChatModel::chatFilePath() const
{
return m_chatFilePath;
}
} // namespace QodeAssist::Chat

View File

@@ -1,5 +1,21 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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
@@ -8,66 +24,29 @@
#include <QAbstractListModel>
#include <QJsonArray>
#include <QJsonObject>
#include <QtQmlIntegration>
#include "context/ContentFile.hpp"
namespace QodeAssist::Chat {
class ChatModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int sessionPromptTokens READ sessionPromptTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int sessionCompletionTokens READ sessionCompletionTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int sessionCachedPromptTokens READ sessionCachedPromptTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int sessionTotalTokens READ sessionTotalTokens NOTIFY sessionUsageChanged FINAL)
Q_PROPERTY(int totalTokens READ totalTokens NOTIFY totalTokensChanged FINAL)
Q_PROPERTY(int tokensThreshold READ tokensThreshold NOTIFY tokensThresholdChanged FINAL)
QML_ELEMENT
public:
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
enum Roles { RoleType = Qt::UserRole, Content };
enum ChatRole { System, User, Assistant };
Q_ENUM(ChatRole)
enum Roles {
RoleType = Qt::UserRole,
Content,
Attachments,
IsRedacted,
Images,
PromptTokens,
CompletionTokens,
CachedPromptTokens,
ReasoningTokens,
TotalTokens
};
Q_ENUM(Roles)
struct ImageAttachment
{
QString fileName; // Original filename
QString storedPath; // Path to stored image file (relative to chat folder)
QString mediaType; // MIME type
};
struct Message
{
ChatRole role;
QString content;
int tokenCount;
QString id;
bool isRedacted = false;
QString signature = QString();
QList<Context::ContentFile> attachments;
QList<ImageAttachment> images;
QString toolName;
QJsonObject toolArguments;
QString toolResult;
int promptTokens = 0;
int completionTokens = 0;
int cachedPromptTokens = 0;
int reasoningTokens = 0;
};
explicit ChatModel(QObject *parent = nullptr);
@@ -76,79 +55,29 @@ public:
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void addMessage(
const QString &content,
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments = {},
const QList<ImageAttachment> &images = {},
bool isRedacted = false,
const QString &signature = QString());
Q_INVOKABLE void addMessage(const QString &content, ChatRole role, const QString &id);
Q_INVOKABLE void clear();
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
QVector<Message> getChatHistory() const;
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
int totalTokens() const;
int tokensThreshold() const;
QString currentModel() const;
QString lastMessageId() const;
Q_INVOKABLE void resetModelTo(int index);
void addToolExecutionStatus(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments);
void dropTrailingAssistantMessage(const QString &requestId);
void setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult);
void updateToolResult(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &result);
void addThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature);
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
void updateMessageContent(const QString &messageId, const QString &newContent);
void setMessageUsage(
const QString &messageId,
int promptTokens,
int completionTokens,
int cachedPromptTokens,
int reasoningTokens);
int sessionPromptTokens() const;
int sessionCompletionTokens() const;
int sessionCachedPromptTokens() const;
int sessionTotalTokens() const;
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
signals:
void modelReseted();
void sessionUsageChanged();
private slots:
void onFileEditApplied(const QString &editId);
void onFileEditRejected(const QString &editId);
void onFileEditArchived(const QString &editId);
void totalTokensChanged();
void tokensThresholdChanged();
private:
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage);
void trim();
int estimateTokenCount(const QString &text) const;
QVector<Message> m_messages;
bool m_loadingFromHistory = false;
QString m_chatFilePath;
int m_totalTokens = 0;
};
} // namespace QodeAssist::Chat

File diff suppressed because it is too large Load Diff

View File

@@ -1,308 +1,86 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 <QPointer>
#include <QQuickItem>
#include <QVariantList>
#include "ChatFileManager.hpp"
#include "ChatModel.hpp"
#include "ClientInterface.hpp"
#include "pluginllmcore/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ChatCompressor;
class AgentRoleController;
class ChatConfigurationController;
class FileEditController;
class InputTokenCounter;
class ChatHistoryStore;
class SessionFileRegistry;
class ChatRootView : public QQuickItem
{
Q_OBJECT
// Possibly Qt bug: QTBUG-131004
// The class type name must be fully qualified
// including the namespace.
// Otherwise qmlls can't find it.
Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL)
Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL)
Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL)
Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL)
Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL)
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 useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL)
Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL)
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL)
Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL)
Q_PROPERTY(QStringList availableAgentRoles READ availableAgentRoles NOTIFY availableAgentRolesChanged FINAL)
Q_PROPERTY(QString currentAgentRole READ currentAgentRole NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL)
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL)
Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL)
Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL)
Q_PROPERTY(QColor backgroundColor READ backgroundColor CONSTANT FINAL)
Q_PROPERTY(QColor primaryColor READ primaryColor CONSTANT FINAL)
Q_PROPERTY(QColor secondaryColor READ secondaryColor CONSTANT FINAL)
Q_PROPERTY(QColor codeColor READ codeColor CONSTANT FINAL)
Q_PROPERTY(bool isSharingCurrentFile READ isSharingCurrentFile NOTIFY
isSharingCurrentFileChanged FINAL)
QML_ELEMENT
public:
ChatRootView(QQuickItem *parent = nullptr);
~ChatRootView() override;
ChatModel *chatModel() const;
QString currentTemplate() const;
void saveHistory(const QString &filePath);
void loadHistory(const QString &filePath);
QColor backgroundColor() const;
QColor primaryColor() const;
QColor secondaryColor() const;
Q_INVOKABLE void showSaveDialog();
Q_INVOKABLE void showLoadDialog();
QColor codeColor() const;
void autosave();
QString getAutosaveFilePath() const;
QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const;
QStringList attachmentFiles() const;
QStringList linkedFiles() const;
Q_INVOKABLE void showAttachFilesDialog();
Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths);
Q_INVOKABLE void removeFileFromAttachList(int index);
Q_INVOKABLE void showLinkFilesDialog();
Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths);
Q_INVOKABLE void removeFileFromLinkList(int index);
Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const;
Q_INVOKABLE void showAddImageDialog();
Q_INVOKABLE bool isImageFile(const QString &filePath) const;
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
Q_INVOKABLE void setIsSyncOpenFiles(bool state);
Q_INVOKABLE void openChatHistoryFolder();
Q_INVOKABLE void openRulesFolder();
Q_INVOKABLE void openSettings();
Q_INVOKABLE void openFileInEditor(const QString &filePath);
Q_INVOKABLE void relocateToSplit();
Q_INVOKABLE void relocateToWindow();
void consumePendingChatFile();
Q_INVOKABLE void updateInputTokensCount();
int inputTokensCount() const;
bool isSyncOpenFiles() const;
void onEditorAboutToClose(Core::IEditor *editor);
void onAppendLinkFileFromEditor(Core::IEditor *editor);
void onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath);
QString chatFileName() const;
Q_INVOKABLE QString chatFilePath() const;
void setRecentFilePath(const QString &filePath);
bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath);
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();
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
bool useTools() const;
void setUseTools(bool enabled);
bool useThinking() const;
void setUseThinking(bool enabled);
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);
Q_INVOKABLE void applyAllFileEditsForCurrentMessage();
Q_INVOKABLE void undoAllFileEditsForCurrentMessage();
Q_INVOKABLE void updateCurrentMessageEditsStats();
Q_INVOKABLE void loadAvailableConfigurations();
Q_INVOKABLE void applyConfiguration(const QString &configName);
QStringList availableConfigurations() const;
QString currentConfiguration() const;
Q_INVOKABLE void compressCurrentChat();
Q_INVOKABLE void cancelCompression();
Q_INVOKABLE void loadAvailableAgentRoles();
Q_INVOKABLE void applyAgentRole(const QString &roleId);
Q_INVOKABLE void openAgentRolesSettings();
QStringList availableAgentRoles() const;
QString currentAgentRole() const;
QString baseSystemPrompt() const;
QString currentAgentRoleDescription() const;
QString currentAgentRoleSystemPrompt() const;
int currentMessageTotalEdits() const;
int currentMessageAppliedEdits() const;
int currentMessagePendingEdits() const;
int currentMessageRejectedEdits() const;
QString lastInfoMessage() const;
bool isThinkingSupport() const;
bool isCompressing() const;
bool isInEditor() const;
void setInEditor(bool value);
QString chatTitle() const;
Q_INVOKABLE void requestNewChat();
bool isSharingCurrentFile() const;
public slots:
void sendMessage(const QString &message);
void sendMessage(const QString &message, bool sharingCurrentFile = false) const;
void copyToClipboard(const QString &text);
void cancelRequest();
void clearAttachmentFiles();
void clearLinkedFiles();
void clearMessages();
signals:
void chatModelChanged();
void currentTemplateChanged();
void attachmentFilesChanged();
void linkedFilesChanged();
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 useToolsChanged();
void useThinkingChanged();
void currentMessageEditsStatsChanged();
void isThinkingSupportChanged();
void availableConfigurationsChanged();
void currentConfigurationChanged();
void availableAgentRolesChanged();
void currentAgentRoleChanged();
void baseSystemPromptChanged();
void isCompressingChanged();
void compressionCompleted(const QString &compressedChatPath);
void compressionFailed(const QString &error);
void isInEditorChanged();
void chatTitleChanged();
void openFilesChanged();
void closeHostRequested();
void isSharingCurrentFileChanged();
private:
QString computeChatTitle() const;
void triggerOpenChatCommand(Utils::Id commandId);
void handOffSession();
bool deferSendForAutoCompress(
const QString &message,
const QStringList &attachments,
const QStringList &linkedFiles,
bool useTools,
bool useThinking);
void dispatchSend(
const QString &message,
const QStringList &attachments,
const QStringList &linkedFiles,
bool useTools,
bool useThinking);
bool hasImageAttachments(const QStringList &attachments) const;
SessionFileRegistry *sessionFileRegistry() const;
Skills::SkillsManager *skillsManager() const;
void generateColors();
QColor generateColor(const QColor &baseColor,
float hueShift,
float saturationMod,
float lightnessMod);
ChatModel *m_chatModel;
PluginLLMCore::PromptProviderChat m_promptProvider;
ClientInterface *m_clientInterface;
ChatFileManager *m_fileManager;
QString m_currentTemplate;
QString m_recentFilePath;
QStringList m_attachmentFiles;
QStringList m_linkedFiles;
struct PendingSend {
QString message;
QStringList attachments;
QStringList linkedFiles;
bool useTools = false;
bool useThinking = false;
bool active = false;
};
PendingSend m_pendingSend;
bool m_isSyncOpenFiles;
bool m_isInEditor = false;
mutable QString m_cachedChatTitle;
QList<Core::IEditor *> m_currentEditors;
bool m_isRequestInProgress;
QString m_lastErrorMessage;
QVariantList m_activeRules;
QString m_lastInfoMessage;
ChatCompressor *m_chatCompressor;
AgentRoleController *m_agentRoleController;
ChatConfigurationController *m_configurationController;
FileEditController *m_fileEditController;
InputTokenCounter *m_tokenCounter;
ChatHistoryStore *m_historyStore;
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
mutable bool m_sessionFileRegistryResolved = false;
mutable QPointer<Skills::SkillsManager> m_skillsManager;
mutable bool m_skillsManagerResolved = false;
QColor m_primaryColor;
QColor m_secondaryColor;
QColor m_codeColor;
};
} // namespace QodeAssist::Chat

View File

@@ -1,322 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ChatSerializer.hpp"
#include "Logger.hpp"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QUuid>
namespace QodeAssist::Chat {
const QString ChatSerializer::VERSION = "0.2";
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
{
if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"};
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
}
QJsonObject root = serializeChat(model, filePath);
QJsonDocument doc(root);
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
}
return {true, QString()};
}
SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
return {false, QString("Failed to open file for reading: %1").arg(filePath)};
}
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError) {
return {false, QString("JSON parse error: %1").arg(error.errorString())};
}
QJsonObject root = doc.object();
QString version = root["version"].toString();
if (!validateVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)};
}
if (!deserializeChat(model, root, filePath)) {
return {false, "Failed to deserialize chat data"};
}
return {true, QString()};
}
QJsonObject ChatSerializer::serializeMessage(
const ChatModel::Message &message, const QString &chatFilePath)
{
QJsonObject messageObj;
messageObj["role"] = static_cast<int>(message.role);
messageObj["content"] = message.content;
messageObj["id"] = message.id;
if (message.isRedacted) {
messageObj["isRedacted"] = true;
}
if (!message.signature.isEmpty()) {
messageObj["signature"] = message.signature;
}
if (message.role == ChatModel::ChatRole::Tool) {
if (!message.toolName.isEmpty())
messageObj["toolName"] = message.toolName;
if (!message.toolArguments.isEmpty())
messageObj["toolArguments"] = message.toolArguments;
if (!message.toolResult.isEmpty())
messageObj["toolResult"] = message.toolResult;
}
if (!message.attachments.isEmpty()) {
QJsonArray attachmentsArray;
for (const auto &attachment : message.attachments) {
QJsonObject attachmentObj;
attachmentObj["fileName"] = attachment.filename;
attachmentObj["storedPath"] = attachment.content;
attachmentsArray.append(attachmentObj);
}
messageObj["attachments"] = attachmentsArray;
}
if (!message.images.isEmpty()) {
QJsonArray imagesArray;
for (const auto &image : message.images) {
QJsonObject imageObj;
imageObj["fileName"] = image.fileName;
imageObj["storedPath"] = image.storedPath;
imageObj["mediaType"] = image.mediaType;
imagesArray.append(imageObj);
}
messageObj["images"] = imagesArray;
}
if (message.promptTokens > 0 || message.completionTokens > 0) {
QJsonObject usageObj;
usageObj["promptTokens"] = message.promptTokens;
usageObj["completionTokens"] = message.completionTokens;
if (message.cachedPromptTokens > 0)
usageObj["cachedPromptTokens"] = message.cachedPromptTokens;
if (message.reasoningTokens > 0)
usageObj["reasoningTokens"] = message.reasoningTokens;
messageObj["usage"] = usageObj;
}
return messageObj;
}
ChatModel::Message ChatSerializer::deserializeMessage(
const QJsonObject &json, const QString &chatFilePath)
{
ChatModel::Message message;
message.role = static_cast<ChatModel::ChatRole>(json["role"].toInt());
message.content = json["content"].toString();
message.id = json["id"].toString();
message.isRedacted = json["isRedacted"].toBool(false);
message.signature = json["signature"].toString();
message.toolName = json["toolName"].toString();
message.toolArguments = json["toolArguments"].toObject();
message.toolResult = json["toolResult"].toString();
if (json.contains("attachments")) {
QJsonArray attachmentsArray = json["attachments"].toArray();
for (const auto &attachmentValue : attachmentsArray) {
QJsonObject attachmentObj = attachmentValue.toObject();
Context::ContentFile attachment;
attachment.filename = attachmentObj["fileName"].toString();
attachment.content = attachmentObj["storedPath"].toString();
message.attachments.append(attachment);
}
}
if (json.contains("images")) {
QJsonArray imagesArray = json["images"].toArray();
for (const auto &imageValue : imagesArray) {
QJsonObject imageObj = imageValue.toObject();
ChatModel::ImageAttachment image;
image.fileName = imageObj["fileName"].toString();
image.storedPath = imageObj["storedPath"].toString();
image.mediaType = imageObj["mediaType"].toString();
message.images.append(image);
}
}
if (json.contains("usage")) {
const QJsonObject usageObj = json["usage"].toObject();
message.promptTokens = usageObj["promptTokens"].toInt();
message.completionTokens = usageObj["completionTokens"].toInt();
message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt();
message.reasoningTokens = usageObj["reasoningTokens"].toInt();
}
return message;
}
QJsonObject ChatSerializer::serializeChat(const ChatModel *model, const QString &chatFilePath)
{
QJsonArray messagesArray;
for (const auto &message : model->getChatHistory()) {
messagesArray.append(serializeMessage(message, chatFilePath));
}
QJsonObject root;
root["version"] = VERSION;
root["messages"] = messagesArray;
return root;
}
bool ChatSerializer::deserializeChat(
ChatModel *model, const QJsonObject &json, const QString &chatFilePath)
{
QJsonArray messagesArray = json["messages"].toArray();
QVector<ChatModel::Message> messages;
messages.reserve(messagesArray.size());
for (const auto &messageValue : messagesArray) {
messages.append(deserializeMessage(messageValue.toObject(), chatFilePath));
}
model->clear();
model->setLoadingFromHistory(true);
for (const auto &message : messages) {
model->addMessage(
message.content,
message.role,
message.id,
message.attachments,
message.images,
message.isRedacted,
message.signature);
if (message.role == ChatModel::ChatRole::Tool) {
model->setToolMessageData(
message.id, message.toolName, message.toolArguments, message.toolResult);
}
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
.arg(message.images.size())
.arg(message.isRedacted)
.arg(message.signature.length()));
}
model->setLoadingFromHistory(false);
return true;
}
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
{
QFileInfo fileInfo(filePath);
QDir dir = fileInfo.dir();
return dir.exists() || dir.mkpath(".");
}
bool ChatSerializer::validateVersion(const QString &version)
{
if (version == VERSION) {
return true;
}
if (version == "0.1") {
LOG_MESSAGE(
"Loading chat from old format 0.1 - images folder structure has changed from _images "
"to _content");
return true;
}
return false;
}
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
{
QFileInfo fileInfo(chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
return QDir(dirPath).filePath(baseName + "_content");
}
bool ChatSerializer::saveContentToStorage(
const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath)
{
QString contentFolder = getChatContentFolder(chatFilePath);
QDir dir;
if (!dir.exists(contentFolder)) {
if (!dir.mkpath(contentFolder)) {
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
return false;
}
}
QFileInfo originalFileInfo(fileName);
QString extension = originalFileInfo.suffix();
QString baseName = originalFileInfo.completeBaseName();
QString uniqueName = QString("%1_%2.%3")
.arg(baseName)
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
.arg(extension);
QString fullPath = QDir(contentFolder).filePath(uniqueName);
QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
QFile file(fullPath);
if (!file.open(QIODevice::WriteOnly)) {
LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
return false;
}
if (file.write(contentData) == -1) {
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
return false;
}
file.close();
storedPath = uniqueName;
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
return true;
}
QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath)
{
QString contentFolder = getChatContentFolder(chatFilePath);
QString fullPath = QDir(contentFolder).filePath(storedPath);
QFile file(fullPath);
if (!file.open(QIODevice::ReadOnly)) {
LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
return QString();
}
QByteArray contentData = file.readAll();
file.close();
return contentData.toBase64();
}
} // namespace QodeAssist::Chat

View File

@@ -1,48 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include "ChatModel.hpp"
namespace QodeAssist::Chat {
struct SerializationResult
{
bool success{false};
QString errorMessage;
};
class ChatSerializer
{
public:
static SerializationResult saveToFile(const ChatModel *model, const QString &filePath);
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath);
// Public for testing purposes
static QJsonObject serializeMessage(const ChatModel::Message &message, const QString &chatFilePath);
static ChatModel::Message deserializeMessage(const QJsonObject &json, const QString &chatFilePath);
static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath);
static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath);
// Content management (images and text files)
static QString getChatContentFolder(const QString &chatFilePath);
static bool saveContentToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
private:
static const QString VERSION;
static constexpr int CURRENT_VERSION = 1;
static bool ensureDirectoryExists(const QString &filePath);
static bool validateVersion(const QString &version);
};
} // namespace QodeAssist::Chat

View File

@@ -1,5 +1,21 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 "ChatUtils.h"
@@ -13,40 +29,4 @@ 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

@@ -1,5 +1,21 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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
@@ -7,6 +23,7 @@
#include <qqmlintegration.h>
namespace QodeAssist::Chat {
// Q_NAMESPACE
class ChatUtils : public QObject
{
@@ -18,7 +35,6 @@ public:
: QObject(parent) {};
Q_INVOKABLE void copyToClipboard(const QString &text);
Q_INVOKABLE QString getSafeMarkdownText(const QString &text) const;
};
} // namespace QodeAssist::Chat

View File

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

View File

@@ -1,48 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <functional>
#include <utils/id.h>
#include <QQuickView>
#include <QShortcut>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class ChatView : public QQuickView
{
Q_OBJECT
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
public:
ChatView(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager);
bool isPin() const;
void setIsPin(bool newIsPin);
signals:
void isPinChanged();
protected:
void closeEvent(QCloseEvent *event) override;
private:
void saveSettings();
void restoreSettings();
void bindCommandShortcut(Utils::Id commandId, const std::function<void()> &onActivated);
bool m_isPin;
};
} // namespace QodeAssist::Chat

View File

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

View File

@@ -1,47 +1,41 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 <QtQuickWidgets/QtQuickWidgets>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class SessionFileRegistry;
class ChatWidget : public QQuickWidget
{
Q_OBJECT
public:
explicit ChatWidget(
QQmlEngine *engine,
SessionFileRegistry *sessionFileRegistry,
Skills::SkillsManager *skillsManager,
bool registerOwnContext = true,
QWidget *parent = nullptr);
explicit ChatWidget(QWidget *parent = nullptr);
~ChatWidget() = default;
Q_INVOKABLE void clear();
Q_INVOKABLE void scrollToBottom();
Q_INVOKABLE void focusInput();
void sendMessage();
void clearSession();
bool isChatFocused() const;
static ChatWidget *focusedInstance();
signals:
void clearPressed();
protected:
void focusInEvent(QFocusEvent *event) override;
};
} // namespace QodeAssist::Chat
}

View File

@@ -1,148 +1,79 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 "ClientInterface.hpp"
#include <LLMQore/BaseClient.hpp>
#include <projectexplorer/buildconfiguration.h>
#include <projectexplorer/target.h>
#include <texteditor/textdocument.h>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QUuid>
#include <texteditor/textdocument.h>
#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 <LLMQore/ToolsManager.hpp>
#include "tools/ReadOriginalHistoryTool.hpp"
#include "tools/TodoTool.hpp"
#include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp"
#include "GeneralSettings.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "PromptTemplateManager.hpp"
#include "ProvidersManager.hpp"
#include "SkillsSettings.hpp"
#include "ToolsSettings.hpp"
#include <RulesLoader.hpp>
#include <context/ChangesManager.h>
#include <sources/skills/SkillsManager.hpp>
namespace QodeAssist::Chat {
ClientInterface::ClientInterface(
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent)
ClientInterface::ClientInterface(ChatModel *chatModel, QObject *parent)
: QObject(parent)
, m_promptProvider(promptProvider)
, m_requestHandler(new LLMCore::RequestHandler(this))
, m_chatModel(chatModel)
, m_contextManager(new Context::ContextManager(this))
{}
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
{
m_skillsManager = skillsManager;
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()
ClientInterface::~ClientInterface() = default;
void ClientInterface::sendMessage(const QString &message, bool includeCurrentFile)
{
cancelRequest();
}
void ClientInterface::sendMessage(
const QString &message,
const QList<QString> &attachments,
const QList<QString> &linkedFiles,
bool useTools,
bool useThinking)
{
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
LOG_MESSAGE("Ignoring empty chat message");
return;
}
cancelRequest();
m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
QList<QString> imageFiles;
QList<QString> textFiles;
for (const QString &filePath : attachments) {
if (isImageFile(filePath)) {
imageFiles.append(filePath);
} else {
textFiles.append(filePath);
}
}
QList<Context::ContentFile> storedAttachments;
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
auto attachFiles = m_contextManager->getContentFiles(textFiles);
for (const auto &file : attachFiles) {
QString storedPath;
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
Context::ContentFile storedFile;
storedFile.filename = file.filename;
storedFile.content = storedPath;
storedAttachments.append(storedFile);
LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath));
}
}
} else if (!textFiles.isEmpty()) {
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)")
.arg(textFiles.size()));
}
QList<ChatModel::ImageAttachment> imageAttachments;
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const QString &imagePath : imageFiles) {
QString base64Data = encodeImageToBase64(imagePath);
if (base64Data.isEmpty()) {
continue;
}
QString storedPath;
QFileInfo fileInfo(imagePath);
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
ChatModel::ImageAttachment imageAttachment;
imageAttachment.fileName = fileInfo.fileName();
imageAttachment.storedPath = storedPath;
imageAttachment.mediaType = getMediaTypeForImage(imagePath);
imageAttachments.append(imageAttachment);
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
}
}
} else if (!imageFiles.isEmpty()) {
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)")
.arg(imageFiles.size()));
}
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments);
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "");
auto &chatAssistantSettings = Settings::chatAssistantSettings();
auto providerName = Settings::generalSettings().caProvider();
auto provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName);
auto provider = LLMCore::ProvidersManager::instance().getProviderByName(providerName);
if (!provider) {
LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName));
@@ -150,304 +81,85 @@ void ClientInterface::sendMessage(
}
auto templateName = Settings::generalSettings().caTemplate();
auto promptTemplate = m_promptProvider->getTemplateByName(templateName);
auto promptTemplate = LLMCore::PromptTemplateManager::instance().getChatTemplateByName(
templateName);
if (!promptTemplate) {
LOG_MESSAGE(QString("No template found with name: %1").arg(templateName));
return;
}
PluginLLMCore::ContextData context;
LLMCore::ContextData context;
context.prefix = message;
context.suffix = "";
const bool isToolsEnabled = useTools;
QString systemPrompt;
if (chatAssistantSettings.useSystemPrompt())
systemPrompt = chatAssistantSettings.systemPrompt();
if (chatAssistantSettings.useSystemPrompt()) {
QString systemPrompt = chatAssistantSettings.systemPrompt();
const QString lastRoleId = chatAssistantSettings.lastUsedRoleId();
if (!lastRoleId.isEmpty()) {
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
if (!role.id.isEmpty())
systemPrompt = systemPrompt + "\n\n" + role.systemPrompt;
}
auto project = PluginLLMCore::RulesLoader::getActiveProject();
if (project) {
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName());
systemPrompt += QString("\n# Active Project path: %1")
.arg(project->projectDirectory().toUrlishString());
if (auto target = project->activeTarget()) {
if (auto buildConfig = target->activeBuildConfiguration()) {
systemPrompt += QString("\n# Active Build directory: %1")
.arg(buildConfig->buildDirectory().toUrlishString());
}
}
QString projectRules
= PluginLLMCore::RulesLoader::loadRulesForProject(project, PluginLLMCore::RulesContext::Chat);
if (!projectRules.isEmpty()) {
systemPrompt += QString("\n# Project Rules\n\n") + projectRules;
}
} else {
systemPrompt += QString("\n# No active project in IDE");
}
if (m_skillsManager && Settings::skillsSettings().enableSkills()) {
QStringList projectSkillDirs;
if (project) {
Settings::ProjectSettings projectSettings(project);
projectSkillDirs = Settings::SkillsSettings::splitLines(
projectSettings.projectSkillDirs());
}
m_skillsManager->configure(
project ? project->projectDirectory().toFSPathString() : QString(),
Settings::SkillsSettings::splitPaths(
Settings::skillsSettings().globalSkillRoots()),
projectSkillDirs);
const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies();
if (!alwaysOnSkills.isEmpty())
systemPrompt += QString("\n\n") + alwaysOnSkills;
const QString skillsCatalog = m_skillsManager->catalogText();
if (!skillsCatalog.isEmpty())
systemPrompt += QString("\n\n") + skillsCatalog;
static const QRegularExpression skillCommand(
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
QStringList invokedSkillNames;
auto skillMatch = skillCommand.globalMatch(message);
while (skillMatch.hasNext()) {
const QString skillName = skillMatch.next().captured(1);
if (invokedSkillNames.contains(skillName))
continue;
const auto invokedSkill = m_skillsManager->findByName(skillName);
if (invokedSkill && !invokedSkill->body.isEmpty()) {
invokedSkillNames << skillName;
systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2")
.arg(invokedSkill->name, invokedSkill->body);
}
}
}
if (!linkedFiles.isEmpty()) {
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
}
context.systemPrompt = systemPrompt;
}
const bool toolHistory = promptTemplate->supportsToolHistory();
QVector<PluginLLMCore::Message> messages;
int toolCallMsgIdx = -1;
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool) {
if (!toolHistory || msg.toolName.isEmpty()) {
continue;
}
if (toolCallMsgIdx < 0) {
PluginLLMCore::Message assistantCall;
assistantCall.role = "assistant";
messages.append(assistantCall);
toolCallMsgIdx = messages.size() - 1;
}
PluginLLMCore::ToolCall call;
call.id = msg.id;
call.name = msg.toolName;
call.arguments = msg.toolArguments;
messages[toolCallMsgIdx].toolCalls.append(call);
PluginLLMCore::Message toolResult;
toolResult.role = "tool";
toolResult.toolCallId = msg.id;
toolResult.toolName = msg.toolName;
toolResult.content = msg.toolResult;
messages.append(toolResult);
continue;
}
toolCallMsgIdx = -1;
if (msg.role == ChatModel::ChatRole::FileEdit) {
continue;
}
PluginLLMCore::Message apiMessage;
apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant";
apiMessage.content = msg.content;
if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const auto &attachment : msg.attachments) {
QString fileContent = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content);
if (!fileContent.isEmpty()) {
QString decodedContent = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8()));
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
.arg(attachment.filename, decodedContent);
}
}
}
apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking);
apiMessage.isRedacted = msg.isRedacted;
apiMessage.signature = msg.signature;
if (provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Image)
&& !m_chatFilePath.isEmpty() && !msg.images.isEmpty()) {
auto apiImages = loadImagesFromStorage(msg.images);
if (!apiImages.isEmpty()) {
apiMessage.images = apiImages;
}
}
messages.append(apiMessage);
}
if (!imageFiles.isEmpty()
&& !provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Image)) {
LOG_MESSAGE(QString("Provider %1 doesn't support images, %2 ignored")
.arg(provider->name(), QString::number(imageFiles.size())));
}
context.history = messages;
QJsonObject payload{
{"model", Settings::generalSettings().caModel()}, {"stream", true}};
provider->prepareRequest(
payload,
promptTemplate,
context,
PluginLLMCore::RequestType::Chat,
useTools,
useThinking);
provider->client()->setMaxToolContinuations(
Settings::toolsSettings().maxToolContinuations());
connect(
provider->client(),
&::LLMQore::BaseClient::chunkReceived,
this,
&ClientInterface::handlePartialResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestCompleted,
this,
&ClientInterface::handleFullResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFinalized,
this,
&ClientInterface::handleRequestFinalized,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFailed,
this,
&ClientInterface::handleRequestFailed,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::toolStarted,
this,
&ClientInterface::handleToolExecutionStarted,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::toolResultReady,
this,
&ClientInterface::handleToolExecutionCompleted,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::thinkingBlockReceived,
this,
&ClientInterface::handleThinkingBlockReceived,
Qt::UniqueConnection);
const QString customEndpoint = Settings::generalSettings().caCustomEndpoint();
const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint
: promptTemplate->endpoint();
auto requestId
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
QJsonObject request{{"id", requestId}};
m_activeRequests[requestId] = {request, provider, !toolHistory};
emit requestStarted(requestId);
if (provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools)
&& provider->toolsManager()) {
if (auto *todoTool = qobject_cast<QodeAssist::Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->setCurrentSessionId(m_chatFilePath);
}
if (auto *historyTool = qobject_cast<QodeAssist::Tools::ReadOriginalHistoryTool *>(
provider->toolsManager()->tool("read_original_history"))) {
historyTool->setCurrentSessionId(m_chatFilePath);
if (includeCurrentFile) {
QString fileContext = getCurrentFileContext();
if (!fileContext.isEmpty()) {
systemPrompt = systemPrompt.append(fileContext);
}
}
QJsonObject providerRequest;
providerRequest["model"] = Settings::generalSettings().caModel();
providerRequest["stream"] = true;
providerRequest["messages"] = m_chatModel->prepareMessagesForRequest(systemPrompt);
if (promptTemplate)
promptTemplate->prepareRequest(providerRequest, context);
else
qWarning("No prompt template found");
if (provider)
provider->prepareRequest(providerRequest, LLMCore::RequestType::Chat);
else
qWarning("No provider found");
LLMCore::LLMConfig config;
config.requestType = LLMCore::RequestType::Chat;
config.provider = provider;
config.promptTemplate = promptTemplate;
config.url = QString("%1%2").arg(Settings::generalSettings().caUrl(), provider->chatEndpoint());
config.providerRequest = providerRequest;
config.multiLineCompletion = false;
config.apiKey = Settings::chatAssistantSettings().apiKey();
QJsonObject request;
request["id"] = QUuid::createUuid().toString();
m_requestHandler->sendLLMRequest(config, request);
}
void ClientInterface::clearMessages()
{
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName);
if (provider && !m_chatFilePath.isEmpty()
&& provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools)
&& provider->toolsManager()) {
if (auto *todoTool = qobject_cast<QodeAssist::Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->clearSession(m_chatFilePath);
}
}
m_chatModel->clear();
LOG_MESSAGE("Chat history cleared");
}
void ClientInterface::cancelRequest()
{
QSet<PluginLLMCore::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->client(), 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();
m_awaitingContinuation.clear();
LOG_MESSAGE("All requests cancelled and state cleared");
auto id = m_chatModel->lastMessageId();
m_requestHandler->cancelRequest(id);
}
void ClientInterface::handleLLMResponse(const QString &response, const QJsonObject &request)
void ClientInterface::handleLLMResponse(const QString &response,
const QJsonObject &request,
bool isComplete)
{
const auto message = response.trimmed();
if (!message.isEmpty()) {
QString messageId = request["id"].toString();
m_chatModel->addMessage(message, ChatModel::ChatRole::Assistant, messageId);
if (isComplete) {
LOG_MESSAGE(
"Message completed. Final response for message " + messageId + ": " + response);
}
}
}
@@ -466,269 +178,13 @@ QString ClientInterface::getCurrentFileContext() const
}
QString fileInfo = QString("Language: %1\nFile: %2\n\n")
.arg(textDocument->mimeType(), textDocument->filePath().toFSPathString());
.arg(textDocument->mimeType(), textDocument->filePath().toString());
QString content = textDocument->document()->toPlainText();
LOG_MESSAGE(QString("Got context from file: %1").arg(textDocument->filePath().toFSPathString()));
LOG_MESSAGE(QString("Got context from file: %1").arg(textDocument->filePath().toString()));
return QString("Current file context:\n%1\nFile content:\n%2").arg(fileInfo, content);
}
QString ClientInterface::getSystemPromptWithLinkedFiles(
const QString &basePrompt, const QList<QString> &linkedFiles) const
{
QString updatedPrompt = basePrompt;
if (!linkedFiles.isEmpty()) {
updatedPrompt += "\n\nLinked files for reference:\n";
auto contentFiles = m_contextManager->getContentFiles(linkedFiles);
for (const auto &file : contentFiles) {
updatedPrompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.filename, file.content);
}
}
return updatedPrompt;
}
Context::ContextManager *ClientInterface::contextManager() const
{
return m_contextManager;
}
void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
if (m_awaitingContinuation.remove(requestId)) {
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(
QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
m_accumulatedResponses[requestId] += partialText;
const RequestContext &ctx = it.value();
handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest);
}
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));
}
LOG_MESSAGE(
"Message completed. Final response for message " + ctx.originalRequest["id"].toString()
+ ": " + finalText);
emit messageReceivedCompletely();
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
m_awaitingContinuation.remove(requestId);
}
void ClientInterface::handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (!m_activeRequests.contains(requestId))
return;
if (!info.usage)
return;
const auto &u = *info.usage;
m_chatModel->setMessageUsage(
requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
emit messageUsageReceived(
u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(u.promptTokens)
.arg(u.completionTokens)
.arg(u.cachedPromptTokens)
.arg(u.reasoningTokens));
}
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
{
auto it = m_activeRequests.find(requestId);
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);
m_awaitingContinuation.remove(requestId);
}
void ClientInterface::handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring thinking block for non-chat request: %1").arg(requestId));
return;
}
if (m_awaitingContinuation.remove(requestId)) {
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(
QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
if (thinking.isEmpty()) {
m_chatModel->addRedactedThinkingBlock(requestId, signature);
} else {
m_chatModel->addThinkingBlock(requestId, thinking, signature);
}
}
void ClientInterface::handleToolExecutionStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments)
{
const auto requestIt = m_activeRequests.constFind(requestId);
if (requestIt == m_activeRequests.constEnd()) {
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
return;
}
if (requestIt->dropPreToolText) {
m_chatModel->dropTrailingAssistantMessage(requestId);
}
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments);
m_awaitingContinuation.insert(requestId);
}
void ClientInterface::handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring tool execution result for non-chat request: %1").arg(requestId));
return;
}
m_chatModel->updateToolResult(requestId, toolId, toolName, toolOutput);
}
bool ClientInterface::isImageFile(const QString &filePath) const
{
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
QFileInfo fileInfo(filePath);
QString extension = fileInfo.suffix().toLower();
return imageExtensions.contains(extension);
}
QString ClientInterface::getMediaTypeForImage(const QString &filePath) const
{
static const QHash<QString, QString> mediaTypes
= {{"png", "image/png"},
{"jpg", "image/jpeg"},
{"jpeg", "image/jpeg"},
{"gif", "image/gif"},
{"webp", "image/webp"},
{"bmp", "image/bmp"},
{"svg", "image/svg+xml"}};
QFileInfo fileInfo(filePath);
QString extension = fileInfo.suffix().toLower();
if (mediaTypes.contains(extension)) {
return mediaTypes[extension];
}
QMimeDatabase mimeDb;
QMimeType mimeType = mimeDb.mimeTypeForFile(filePath);
return mimeType.name();
}
QString ClientInterface::encodeImageToBase64(const QString &filePath) const
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath));
return QString();
}
QByteArray imageData = file.readAll();
file.close();
return imageData.toBase64();
}
QVector<PluginLLMCore::ImageAttachment> ClientInterface::loadImagesFromStorage(
const QList<ChatModel::ImageAttachment> &storedImages) const
{
QVector<PluginLLMCore::ImageAttachment> apiImages;
for (const auto &storedImage : storedImages) {
QString base64Data
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;
}
PluginLLMCore::ImageAttachment apiImage;
apiImage.data = base64Data;
apiImage.mediaType = storedImage.mediaType;
apiImage.isUrl = false;
apiImages.append(apiImage);
}
return apiImages;
}
void ClientInterface::setChatFilePath(const QString &filePath)
{
if (!m_chatFilePath.isEmpty() && m_chatFilePath != filePath) {
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName);
if (provider
&& provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools)
&& provider->toolsManager()) {
if (auto *todoTool = qobject_cast<QodeAssist::Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->clearSession(m_chatFilePath);
}
}
}
m_chatFilePath = filePath;
m_chatModel->setChatFilePath(filePath);
}
QString ClientInterface::chatFilePath() const
{
return m_chatFilePath;
}
} // namespace QodeAssist::Chat

View File

@@ -1,22 +1,30 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 <QSet>
#include <QString>
#include <QVector>
#include "ChatModel.hpp"
#include "Provider.hpp"
#include "pluginllmcore/IPromptProvider.hpp"
#include <LLMQore/BaseClient.hpp>
#include <context/ContextManager.hpp>
namespace QodeAssist::Skills {
class SkillsManager;
}
#include "RequestHandler.hpp"
namespace QodeAssist::Chat {
@@ -25,77 +33,22 @@ class ClientInterface : public QObject
Q_OBJECT
public:
explicit ClientInterface(
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
explicit ClientInterface(ChatModel *chatModel, QObject *parent = nullptr);
~ClientInterface();
void setSkillsManager(Skills::SkillsManager *skillsManager);
void sendMessage(
const QString &message,
const QList<QString> &attachments = {},
const QList<QString> &linkedFiles = {},
bool useTools = false,
bool useThinking = false);
void sendMessage(const QString &message, bool includeCurrentFile = false);
void clearMessages();
void cancelRequest();
Context::ContextManager *contextManager() const;
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
signals:
void errorOccurred(const QString &error);
void messageReceivedCompletely();
void requestStarted(const QString &requestId);
void messageUsageReceived(
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
private slots:
void handlePartialResponse(const QString &requestId, const QString &partialText);
void handleFullResponse(const QString &requestId, const QString &fullText);
void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleRequestFailed(const QString &requestId, const QString &error);
void handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature);
void handleToolExecutionStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments);
void handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput);
private:
void handleLLMResponse(const QString &response, const QJsonObject &request);
void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete);
QString getCurrentFileContext() const;
QString getSystemPromptWithLinkedFiles(
const QString &basePrompt, const QList<QString> &linkedFiles) const;
bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const;
QString encodeImageToBase64(const QString &filePath) const;
QVector<PluginLLMCore::ImageAttachment> loadImagesFromStorage(const QList<ChatModel::ImageAttachment> &storedImages) const;
struct RequestContext
{
QJsonObject originalRequest;
PluginLLMCore::Provider *provider;
bool dropPreToolText = false;
};
PluginLLMCore::IPromptProvider *m_promptProvider = nullptr;
LLMCore::RequestHandler *m_requestHandler;
ChatModel *m_chatModel;
Context::ContextManager *m_contextManager;
Skills::SkillsManager *m_skillsManager = nullptr;
QString m_chatFilePath;
QHash<QString, RequestContext> m_activeRequests;
QHash<QString, QString> m_accumulatedResponses;
QSet<QString> m_awaitingContinuation;
};
} // namespace QodeAssist::Chat

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "FileItem.hpp"
#include <QDesktopServices>
#include <QUrl>
#include <coreplugin/editormanager/editormanager.h>
#include <logger/Logger.hpp>
#include <utils/filepath.h>
namespace QodeAssist::Chat {
FileItem::FileItem(QQuickItem *parent)
: QQuickItem(parent)
{}
void FileItem::openFileInEditor()
{
if (m_filePath.isEmpty()) {
return;
}
Utils::FilePath filePathObj = Utils::FilePath::fromString(m_filePath);
Core::IEditor *editor = Core::EditorManager::openEditor(filePathObj);
if (!editor) {
LOG_MESSAGE(QString("Failed to open file in editor: %1").arg(m_filePath));
}
}
void FileItem::openFileInExternalEditor()
{
if (m_filePath.isEmpty()) {
return;
}
bool success = QDesktopServices::openUrl(QUrl::fromLocalFile(m_filePath));
if (success) {
LOG_MESSAGE(QString("Opened file in external application: %1").arg(m_filePath));
} else {
LOG_MESSAGE(QString("Failed to open file externally: %1").arg(m_filePath));
}
}
QString FileItem::filePath() const
{
return m_filePath;
}
void FileItem::setFilePath(const QString &newFilePath)
{
if (m_filePath == newFilePath)
return;
m_filePath = newFilePath;
emit filePathChanged();
}
} // namespace QodeAssist::Chat

View File

@@ -1,32 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QQuickItem>
namespace QodeAssist::Chat {
class FileItem: public QQuickItem
{
Q_OBJECT
QML_NAMED_ELEMENT(FileItem)
Q_PROPERTY(QString filePath READ filePath WRITE setFilePath NOTIFY filePathChanged)
public:
FileItem(QQuickItem *parent = nullptr);
Q_INVOKABLE void openFileInEditor();
Q_INVOKABLE void openFileInExternalEditor();
QString filePath() const;
void setFilePath(const QString &newFilePath);
signals:
void filePathChanged();
private:
QString m_filePath;
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,51 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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>
#include "ChatData.hpp"
#include <qobject.h>
#include <qqmlintegration.h>
namespace QodeAssist::Chat {
Q_NAMESPACE
class MessagePart
{
Q_GADGET
Q_PROPERTY(MessagePartType type MEMBER type CONSTANT FINAL)
Q_PROPERTY(PartType type MEMBER type CONSTANT FINAL)
Q_PROPERTY(QString text MEMBER text CONSTANT FINAL)
Q_PROPERTY(QString language MEMBER language CONSTANT FINAL)
Q_PROPERTY(QString imageData MEMBER imageData CONSTANT FINAL)
Q_PROPERTY(QString mediaType MEMBER mediaType CONSTANT FINAL)
QML_VALUE_TYPE(messagePart)
public:
MessagePartType type;
enum PartType { Code, Text };
Q_ENUM(PartType)
PartType type;
QString text;
QString language;
QString imageData; // Base64 data or URL
QString mediaType; // e.g., "image/png", "image/jpeg"
};
class MessagePartType : public MessagePart
{
Q_GADGET
};
QML_NAMED_ELEMENT(MessagePart)
QML_FOREIGN_NAMESPACE(QodeAssist::Chat::MessagePartType)
} // namespace QodeAssist::Chat

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 548 B

View File

@@ -1,11 +0,0 @@
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_37_14)">
<path d="M22 10.1053V36.7368C22 41.8547 17.525 46 12 46C6.475 46 2 41.8547 2 36.7368V7.78947C2 4.59368 4.8 2 8.25 2C11.7 2 15.75 4.59368 15.75 7.78947V35.5789C15.75 36.8526 13.375 39.0526 12 39.0526C10.625 39.0526 8.25 36.8526 8.25 35.5789V21.6842V8.94737" stroke="black" stroke-width="3" stroke-linecap="round"/>
<path d="M22 10.1053V36.7368C22 41.8547 17.525 46 12 46C6.475 46 2 41.8547 2 36.7368V7.78947C2 4.59368 4.8 2 8.25 2C11.7 2 15.75 4.59368 15.75 7.78947V35.5789C15.75 36.8526 13.375 39.0526 12 39.0526C10.625 39.0526 8.25 36.8526 8.25 35.5789V21.6842V8.94737" stroke="black" stroke-width="3" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_37_14">
<rect width="24" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 869 B

View File

@@ -1,11 +0,0 @@
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_51_20)">
<path d="M22 10.1053V36.7368C22 41.8547 17.525 46 12 46C6.475 46 2 41.8547 2 36.7368V7.78947C2 4.59368 4.8 2 8.25 2C11.7 2 15.75 4.59368 15.75 7.78947V35.5789C15.75 36.8526 13.375 39.0526 12 39.0526C10.625 39.0526 8.25 36.8526 8.25 35.5789V21.6842V8.94737" stroke="white" stroke-width="3" stroke-linecap="round"/>
<path d="M22 10.1053V36.7368C22 41.8547 17.525 46 12 46C6.475 46 2 41.8547 2 36.7368V7.78947C2 4.59368 4.8 2 8.25 2C11.7 2 15.75 4.59368 15.75 7.78947V35.5789C15.75 36.8526 13.375 39.0526 12 39.0526C10.625 39.0526 8.25 36.8526 8.25 35.5789V21.6842V8.94737" stroke="white" stroke-width="3" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_51_20">
<rect width="24" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 869 B

View File

@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 523 B

View File

@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 548 B

View File

@@ -1,8 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 822 B

View File

@@ -1,10 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_41_14)">
<path d="M0 0L24 24M0 24L24 0" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_41_14">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 353 B

View File

@@ -1,10 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_41_14)">
<path d="M0 0L24 24M0 24L24 0" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_41_14">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 353 B

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Archive/compress icon: downward arrows pointing to center with horizontal lines -->
<line x1="12" y1="3" x2="12" y2="10" />
<polyline points="9 7 12 10 15 7" />
<line x1="12" y1="21" x2="12" y2="14" />
<polyline points="9 17 12 14 15 17" />
<line x1="4" y1="12" x2="20" y2="12" stroke-width="3" />
</svg>

Before

Width:  |  Height:  |  Size: 487 B

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h12v2H3v-2z"/>
<circle cx="19" cy="17" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 233 B

View File

@@ -1,12 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 624 B

View File

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>

Before

Width:  |  Height:  |  Size: 304 B

View File

@@ -1,12 +0,0 @@
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_49_24)">
<path d="M10 12L10 32L10 12Z" fill="black"/>
<path d="M10 12L10 32" stroke="black" stroke-width="3"/>
<path d="M1.50001 12.484C1.50001 -1.99999 18.5 -1.99999 18.5 12.484M1.5 31.5334C1.50001 46 18.5 46 18.5 31.5334" stroke="black" stroke-width="3" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_49_24">
<rect width="20" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 513 B

View File

@@ -1,12 +0,0 @@
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_51_24)">
<path d="M10 12L10 32Z" fill="white"/>
<path d="M10 12L10 32" stroke="white" stroke-width="3"/>
<path d="M1.50001 12.484C1.50001 -1.99999 18.5 -1.99999 18.5 12.484M1.5 31.5334C1.50001 46 18.5 46 18.5 31.5334" stroke="white" stroke-width="3" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_51_24">
<rect width="20" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 507 B

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 370 B

View File

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

Before

Width:  |  Height:  |  Size: 526 B

View File

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

Before

Width:  |  Height:  |  Size: 348 B

View File

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

Before

Width:  |  Height:  |  Size: 348 B

View File

@@ -1,16 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 599 B

View File

@@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 370 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>

Before

Width:  |  Height:  |  Size: 962 B

View File

@@ -1,4 +0,0 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black" fill-opacity="0.6"/>
<path d="M6 35L38 6" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,3 +0,0 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,11 +0,0 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_82_71)">
<path d="M10.7777 0.0435181C14.2316 -0.253961 17.6161 0.979215 20.0629 3.42633C23.4139 6.77767 24.3012 11.6719 22.7299 15.8433C22.9016 15.988 23.0706 16.1419 23.2377 16.3072L42.2221 34.2203C42.2288 34.2268 42.2353 34.2344 42.2426 34.2408C44.4752 36.4735 44.4752 40.1064 42.2426 42.3394C40.0096 44.5717 36.4035 44.5446 34.1713 42.3121C34.1617 42.3031 34.1528 42.2937 34.144 42.2838L16.3871 23.1519C16.2254 22.9894 16.0746 22.8196 15.933 22.6451C11.7604 24.2194 6.86327 23.3335 3.50919 19.98C1.06298 17.5327 -0.171482 14.1483 0.126373 10.6949C0.160109 10.3034 0.41818 9.96685 0.78653 9.83258C1.15602 9.69759 1.57009 9.78945 1.84805 10.067L7.53555 15.7535L13.8402 13.7574L15.8363 7.4527L10.1488 1.7652C9.87057 1.48716 9.77945 1.07345 9.91348 0.704651C10.0489 0.335072 10.3852 0.0774496 10.7777 0.0435181ZM37.3656 34.7496L37.3129 34.9302L37.1586 35.4673L36.8363 35.5679L36.6195 35.2047L36.4623 34.942L36.2357 35.148L35.725 35.6148L35.5746 35.7525L35.6791 35.9283L35.9184 36.3287L35.7104 36.6548L35.1742 36.5543L34.9408 36.5093L34.8852 36.7418L34.7572 37.275L34.7123 37.4644L34.8842 37.5543L35.3891 37.8179V38.1802L34.8842 38.4449L34.7123 38.5347L34.7572 38.7242L34.8852 39.2574L34.9408 39.4898L35.1742 39.4449L35.7104 39.3433L35.9184 39.6695L35.6791 40.0709L35.5746 40.2466L35.725 40.3843L36.2357 40.8511L36.4623 41.0572L36.6195 40.7945L36.8363 40.4302L37.1586 40.5308L37.3129 41.0689L37.3656 41.2496H38.6352L38.6879 41.0689L38.8412 40.5308L39.1635 40.4302L39.3813 40.7945L39.5385 41.0572L39.765 40.8511L40.2758 40.3843L40.4262 40.2466L40.3217 40.0709L40.0815 39.6695L40.2895 39.3433L40.8266 39.4449L41.06 39.4898L41.1156 39.2574L41.2436 38.7242L41.2885 38.5347L41.1166 38.4449L40.6117 38.1802V37.8179L41.1166 37.5543L41.2885 37.4644L41.2436 37.275L41.1156 36.7418L41.06 36.5093L40.8266 36.5543L40.2895 36.6548L40.0815 36.3287L40.3217 35.9283L40.4262 35.7525L40.2758 35.6148L39.765 35.148L39.5385 34.942L39.3813 35.2047L39.1635 35.5679L38.8412 35.4673L38.6879 34.9302L38.6352 34.7496H37.3656Z" fill="black" fill-opacity="0.6"/>
<path d="M6 36L38 7" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_82_71">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,10 +0,0 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_82_50)">
<path d="M10.7775 0.0441895C14.2315 -0.253375 17.6159 0.979824 20.0627 3.427C23.4135 6.77842 24.3011 11.6726 22.7297 15.844C22.9013 15.9886 23.0714 16.1416 23.2385 16.3069L42.2219 34.2209C42.2285 34.2274 42.2352 34.2341 42.2424 34.2405C44.475 36.4732 44.475 40.1061 42.2424 42.3391C40.0094 44.5715 36.4033 44.5444 34.1711 42.3118C34.1615 42.3028 34.1525 42.2934 34.1437 42.2834L16.3869 23.1516C16.2251 22.9891 16.0745 22.8193 15.9328 22.6448C11.7602 24.2191 6.86304 23.3333 3.50897 19.9797C1.06276 17.5324 -0.171773 14.148 0.12616 10.6946C0.159908 10.3029 0.418723 9.96644 0.787292 9.83228C1.15667 9.69748 1.56997 9.78926 1.84784 10.0667L7.53534 15.7532L13.84 13.7571L15.8361 7.45239L10.1486 1.76489C9.87052 1.48684 9.78022 1.07306 9.91425 0.704346C10.0498 0.334991 10.3852 0.0781082 10.7775 0.0441895ZM37.3654 34.7502L37.3127 34.9309L37.1584 35.468L36.8361 35.5686L36.6193 35.2053L36.4621 34.9426L36.2355 35.1487L35.7248 35.6155L35.5744 35.7532L35.6789 35.929L35.9182 36.3293L35.7101 36.6555L35.174 36.5549L34.9406 36.51L34.8849 36.7424L34.757 37.2756L34.7121 37.4651L34.884 37.5549L35.3889 37.8186V38.1809L34.884 38.4456L34.7121 38.5354L34.757 38.7249L34.8849 39.2581L34.9406 39.4905L35.174 39.4456L35.7101 39.344L35.9182 39.6702L35.6789 40.0715L35.5744 40.2473L35.7248 40.385L36.2355 40.8518L36.4621 41.0579L36.6193 40.7952L36.8361 40.4309L37.1584 40.5315L37.3127 41.0696L37.3654 41.2502H38.6349L38.6877 41.0696L38.841 40.5315L39.1633 40.4309L39.381 40.7952L39.5383 41.0579L39.7648 40.8518L40.2756 40.385L40.426 40.2473L40.3215 40.0715L40.0812 39.6702L40.2892 39.344L40.8264 39.4456L41.0598 39.4905L41.1154 39.2581L41.2433 38.7249L41.2883 38.5354L41.1164 38.4456L40.6115 38.1809V37.8186L41.1164 37.5549L41.2883 37.4651L41.2433 37.2756L41.1154 36.7424L41.0598 36.51L40.8264 36.5549L40.2892 36.6555L40.0812 36.3293L40.3215 35.929L40.426 35.7532L40.2756 35.6155L39.7648 35.1487L39.5383 34.9426L39.381 35.2053L39.1633 35.5686L38.841 35.468L38.6877 34.9309L38.6349 34.7502H37.3654Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_82_50">
<rect width="44" height="44" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,16 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 663 B

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 552 B

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 559 B

40
ChatView/qml/Badge.qml Normal file
View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2024 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 text: badgeText.text
property alias fontColor: badgeText.color
width: badgeText.implicitWidth + radius
height: badgeText.implicitHeight + 6
color: "lightgreen"
radius: height / 2
border.width: 1
border.color: "gray"
Text {
id: badgeText
anchors.centerIn: parent
}
}

111
ChatView/qml/ChatItem.qml Normal file
View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2024 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 ComponentBehavior: Bound
import QtQuick
import ChatView
import "./dialog"
Rectangle {
id: root
property alias msgModel: msgCreator.model
property color fontColor
property color codeBgColor
property color selectionColor
height: msgColumn.height
radius: 8
Column {
id: msgColumn
anchors.verticalCenter: parent.verticalCenter
width: parent.width
spacing: 5
Repeater {
id: msgCreator
delegate: Loader {
id: msgCreatorDelegate
// Fix me:
// why does `required property MessagePart modelData` not work?
required property var modelData
width: parent.width
sourceComponent: {
// If `required property MessagePart modelData` is used
// and conversion to MessagePart fails, you're left
// with a nullptr. This tests that to prevent crashing.
if(!modelData) {
return undefined;
}
switch(modelData.type) {
case MessagePart.Text: return textComponent;
case MessagePart.Code: return codeBlockComponent;
default: return textComponent;
}
}
Component {
id: textComponent
TextComponent {
itemData: msgCreatorDelegate.modelData
}
}
Component {
id: codeBlockComponent
CodeBlockComponent {
itemData: msgCreatorDelegate.modelData
}
}
}
}
}
component TextComponent : TextBlock {
required property var itemData
height: implicitHeight + 10
verticalAlignment: Text.AlignVCenter
leftPadding: 10
text: itemData.text
color: root.fontColor
selectionColor: root.selectionColor
}
component CodeBlockComponent : CodeBlock {
required property var itemData
anchors {
left: parent.left
leftMargin: 10
right: parent.right
rightMargin: 10
}
code: itemData.text
language: itemData.language
color: root.codeBgColor
selectionColor: root.selectionColor
}
}

View File

@@ -1,225 +1,66 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Basic as QQC
import QtQuick.Layouts
import ChatView
import UIControls
import Qt.labs.platform as Platform
import "./chatparts"
import "./controls"
ChatRootView {
id: root
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
}
Rectangle {
id: bg
anchors.fill: parent
color: palette.window
}
SplitDropZone {
anchors.fill: parent
z: 99
onFilesDroppedToAttach: (urlStrings) => {
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
if (localPaths.length > 0) {
root.addFilesToAttachList(localPaths)
}
}
onFilesDroppedToLink: (urlStrings) => {
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
if (localPaths.length > 0) {
root.addFilesToLinkList(localPaths)
}
}
}
QoABusyOverlay {
id: compressingOverlay
z: 50
anchors.fill: mainColumn
anchors.topMargin: topBar.height
anchors.bottomMargin: bottomBar.height
active: root.isCompressing
text: qsTr("Compressing chat…")
color: root.backgroundColor
}
ColumnLayout {
id: mainColumn
anchors.fill: parent
spacing: 0
TopBar {
id: topBar
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + 10
isInEditor: root.isInEditor
saveButton.onClicked: root.showSaveDialog()
loadButton.onClicked: root.showLoadDialog()
clearButton.onClicked: root.clearChat()
newChatButton.onClicked: root.requestNewChat()
tokensBadge {
readonly property int sessionPrompt: root.chatModel.sessionPromptTokens || 0
readonly property int sessionCompletion: root.chatModel.sessionCompletionTokens || 0
readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens || 0
text: sessionCached > 0
? qsTr("next ~%1 · session ↑%2 ↓%3 ↻%4")
.arg(root.inputTokensCount)
.arg(sessionPrompt)
.arg(sessionCompletion)
.arg(sessionCached)
: qsTr("next ~%1 · session ↑%2 ↓%3")
.arg(root.inputTokensCount)
.arg(sessionPrompt)
.arg(sessionCompletion)
ToolTip.text: sessionCached > 0
? qsTr("next request (estimate) · session prompt ↑ / completion ↓ / cached ↻ (provider cache hits)")
: qsTr("next request (estimate) · session prompt ↑ / completion ↓")
}
recentPath {
text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
}
openChatHistory.onClicked: root.openChatHistoryFolder()
contextButton.onClicked: contextViewer.open()
pinButton {
visible: typeof _chatview !== 'undefined'
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
}
relocateButton {
icon.source: (typeof _chatview !== 'undefined')
? "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
: "qrc:/qt/qml/ChatView/icons/open-in-window.svg"
ToolTip.text: (typeof _chatview !== 'undefined')
? qsTr("Move this chat to an editor tab")
: qsTr("Move this chat to a separate window")
onClicked: {
if (typeof _chatview !== 'undefined')
root.relocateToSplit()
else
root.relocateToWindow()
}
}
toolsButton {
checked: root.useTools
onCheckedChanged: {
root.useTools = toolsButton.checked
}
}
thinkingMode {
checked: root.useThinking
enabled: root.isThinkingSupport
onCheckedChanged: {
root.useThinking = thinkingMode.checked
}
}
settingsButton.onClicked: root.openSettings()
configSelector {
model: root.availableConfigurations
displayText: root.currentConfiguration
onActivated: function(index) {
if (index > 0) {
root.applyConfiguration(root.availableConfigurations[index])
}
}
popup.onAboutToShow: {
root.loadAvailableConfigurations()
}
}
roleSelector {
model: root.availableAgentRoles
displayText: root.currentAgentRole
onActivated: function(index) {
root.applyAgentRole(root.availableAgentRoles[index])
}
popup.onAboutToShow: {
root.loadAvailableAgentRoles()
}
}
anchors {
fill: parent
}
spacing: 10
ListView {
id: chatListView
property bool userScrolledUp: false
Layout.fillWidth: true
Layout.fillHeight: true
leftMargin: 5
model: root.chatModel
clip: true
spacing: 0
spacing: 10
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 2000
onMovingChanged: {
if (moving) {
userScrolledUp = !atYEnd
}
}
onAtYEndChanged: {
if (atYEnd) {
userScrolledUp = false
}
}
delegate: Loader {
id: componentLoader
delegate: ChatItem {
required property var model
required property int index
width: ListView.view.width - scroll.width
sourceComponent: {
if (model.roleType === ChatModel.Tool) {
return toolMessageComponent
} else if (model.roleType === ChatModel.FileEdit) {
return fileEditMessageComponent
} else if (model.roleType === ChatModel.Thinking) {
return thinkingMessageComponent
} else {
return chatItemComponent
}
}
msgModel: root.chatModel.processMessageContent(model.content)
color: model.roleType === ChatModel.User ? root.primaryColor : root.secondaryColor
fontColor: root.primaryColor.hslLightness > 0.5 ? "black" : "white"
codeBgColor: root.codeColor
selectionColor: root.primaryColor.hslLightness > 0.5 ? Qt.darker(root.primaryColor, 1.5)
: Qt.lighter(root.primaryColor, 1.5)
}
@@ -228,147 +69,19 @@ ChatRootView {
height: 30
}
ScrollBar.vertical: QQC.ScrollBar {
ScrollBar.vertical: ScrollBar {
id: scroll
}
Rectangle {
id: scrollToBottomButton
anchors {
bottom: parent.bottom
horizontalCenter: parent.horizontalCenter
bottomMargin: 10
}
width: 36
height: 36
radius: 18
color: palette.button
border.color: palette.mid
border.width: 1
visible: chatListView.userScrolledUp
opacity: 0.9
z: 100
Text {
anchors.centerIn: parent
text: "▼"
font.pixelSize: 14
color: palette.buttonText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
chatListView.userScrolledUp = false
root.scrollToBottom()
}
}
Behavior on visible {
enabled: false
}
}
onCountChanged: {
if (!userScrolledUp) {
root.scrollToBottom()
}
root.scrollToBottom()
}
onContentHeightChanged: {
if (!userScrolledUp && atYEnd) {
if (atYEnd) {
root.scrollToBottom()
}
}
Component {
id: chatItemComponent
ChatItem {
id: chatItemInstance
width: parent.width
chatViewport: chatListView
msgModel: root.chatModel.processMessageContent(model.content)
messageAttachments: model.attachments
messageImages: model.images
chatFilePath: root.chatFilePath()
isUserMessage: model.roleType === ChatModel.User
messageIndex: index
textFontFamily: root.textFontFamily
codeFontFamily: root.codeFontFamily
codeFontSize: root.codeFontSize
textFontSize: root.textFontSize
textFormat: root.textFormat
promptTokens: model.promptTokens || 0
completionTokens: model.completionTokens || 0
cachedPromptTokens: model.cachedPromptTokens || 0
reasoningTokens: model.reasoningTokens || 0
onResetChatToMessage: function(idx) {
messageInput.text = model.content
messageInput.cursorPosition = model.content.length
root.chatModel.resetModelTo(idx)
}
onOpenFileRequested: function(filePath) {
root.openFileInEditor(filePath)
}
}
}
Component {
id: toolMessageComponent
ToolBlock {
width: parent.width
toolContent: model.content
}
}
Component {
id: fileEditMessageComponent
FileEditBlock {
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)
}
}
}
Component {
id: thinkingMessageComponent
ThinkingBlock {
width: parent.width
thinkingContent: {
let content = model.content
let signatureStart = content.indexOf("\n[Signature:")
if (signatureStart >= 0) {
return content.substring(0, signatureStart)
}
return content
}
isRedacted: model.isRedacted !== undefined ? model.isRedacted : false
}
}
}
ScrollView {
@@ -381,354 +94,93 @@ ChatRootView {
QQC.TextArea {
id: messageInput
placeholderText: Qt.platform.os === "osx"
? qsTr("Type your message here... (⌘+↩ to send)")
: qsTr("Type your message here... (Ctrl+Enter to send)")
placeholderTextColor: palette.mid
color: palette.text
placeholderText: qsTr("Type your message here...")
placeholderTextColor: "#888"
color: root.primaryColor.hslLightness > 0.5 ? "black" : "white"
background: Rectangle {
radius: 2
color: palette.base
border.color: messageInput.activeFocus ? palette.highlight : palette.button
color: root.primaryColor
border.color: root.primaryColor.hslLightness > 0.5 ? Qt.lighter(root.primaryColor, 1.5)
: Qt.darker(root.primaryColor, 1.5)
border.width: 1
Behavior on border.color {
ColorAnimation { duration: 150 }
}
Rectangle {
anchors.fill: parent
color: palette.highlight
opacity: messageInput.hovered ? 0.1 : 0
radius: parent.radius
}
}
onTextChanged: {
root.calculateMessageTokensCount(messageInput.text)
var cursorPos = messageInput.cursorPosition
var textBefore = messageInput.text.substring(0, cursorPos)
var atIndex = textBefore.lastIndexOf('@')
if (atIndex >= 0) {
var query = textBefore.substring(atIndex + 1)
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
fileMentionPopup.updateSearch(query)
skillCommandPopup.dismiss()
return
}
}
fileMentionPopup.dismiss()
const slashIndex = textBefore.lastIndexOf('/')
if (slashIndex >= 0) {
const beforeSlash = slashIndex === 0
? ' '
: textBefore.charAt(slashIndex - 1)
const skillQuery = textBefore.substring(slashIndex + 1)
if ((beforeSlash === ' ' || beforeSlash === '\n')
&& /^[a-z0-9-]*$/.test(skillQuery)) {
skillCommandPopup.updateSearch(skillQuery)
return
}
}
skillCommandPopup.dismiss()
}
Keys.onPressed: function(event) {
if (fileMentionPopup.visible) {
if (event.key === Qt.Key_Down) {
fileMentionPopup.moveDown()
event.accepted = true
} else if (event.key === Qt.Key_Up) {
fileMentionPopup.moveUp()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applyMentionSelection()
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
fileMentionPopup.dismiss()
event.accepted = true
}
} else if (skillCommandPopup.visible) {
if (event.key === Qt.Key_Down) {
skillCommandPopup.moveDown()
event.accepted = true
} else if (event.key === Qt.Key_Up) {
skillCommandPopup.moveUp()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applySkillSelection()
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
skillCommandPopup.dismiss()
event.accepted = true
}
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
RowLayout {
Layout.fillWidth: true
attachedFilesModel: root.attachmentFiles
iconPath: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/attach-file-dark.svg"
: "qrc:/qt/qml/ChatView/icons/attach-file-light.svg"
accentColor: Qt.tint(palette.mid, Qt.rgba(0, 0.8, 0.3, 0.4))
onRemoveFileFromListByIndex: (index) => root.removeFileFromAttachList(index)
}
spacing: 5
AttachedFilesPlace {
id: linkedFilesPlace
Button {
id: sendButton
Layout.fillWidth: true
attachedFilesModel: root.linkedFiles
iconPath: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
: "qrc:/qt/qml/ChatView/icons/link-file-light.svg"
accentColor: Qt.tint(palette.mid, Qt.rgba(0, 0.3, 0.8, 0.4))
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
isCompressing: root.isCompressing
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.text: !root.isRequestInProgress ? qsTr("Send") : qsTr("Stop")
sendButton.ToolTip.text: !root.isRequestInProgress ? qsTr("Send message to LLM %1").arg(Qt.platform.os === "osx" ? "Cmd+Return" : "Ctrl+Return")
: qsTr("Stop")
compressButton.onClicked: compressConfirmDialog.open()
cancelCompressButton.onClicked: root.cancelCompression()
syncOpenFiles {
checked: root.isSyncOpenFiles
onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
Layout.alignment: Qt.AlignBottom
text: qsTr("Send")
onClicked: root.sendChatMessage()
}
attachFiles.onClicked: root.showAttachFilesDialog()
attachImages.onClicked: root.showAddImageDialog()
linkFiles.onClicked: root.showLinkFilesDialog()
Button {
id: stopButton
Layout.alignment: Qt.AlignBottom
text: qsTr("Stop")
onClicked: root.cancelRequest()
}
Button {
id: clearButton
Layout.alignment: Qt.AlignBottom
text: qsTr("Clear Chat")
onClicked: root.clearChat()
}
CheckBox {
id: sharingCurrentFile
text: "Share current file with models"
checked: root.isSharingCurrentFile
}
}
}
Row {
id: bar
layoutDirection: Qt.RightToLeft
anchors {
left: parent.left
leftMargin: 5
right: parent.right
rightMargin: scroll.width
}
spacing: 10
Badge {
text: qsTr("tokens:%1/%2").arg(root.chatModel.totalTokens).arg(root.chatModel.tokensThreshold)
color: root.codeColor
fontColor: root.primaryColor.hslLightness > 0.5 ? "black" : "white"
}
}
function clearChat() {
root.clearMessages()
root.clearAttachmentFiles()
root.updateInputTokensCount()
root.chatModel.clear()
}
function scrollToBottom() {
Qt.callLater(chatListView.positionViewAtEnd)
}
function focusInput() {
messageInput.forceActiveFocus()
}
function applyMentionSelection() {
var result = fileMentionPopup.applyCurrentSelection(
messageInput.text, messageInput.cursorPosition, root.useTools)
if (result.text !== undefined) {
messageInput.text = result.text
messageInput.cursorPosition = result.cursorPosition
}
}
function applySkillSelection() {
const name = skillCommandPopup.currentName()
if (name === "")
return
const cursorPos = messageInput.cursorPosition
const textBefore = messageInput.text.substring(0, cursorPos)
const slashIndex = textBefore.lastIndexOf('/')
if (slashIndex < 0)
return
const before = messageInput.text.substring(0, slashIndex)
const after = messageInput.text.substring(cursorPos)
const token = '/' + name + ' '
messageInput.text = before + token + after
messageInput.cursorPosition = before.length + token.length
skillCommandPopup.dismiss()
}
function sendChatMessage() {
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
root.sendMessage(messageInput.text, sharingCurrentFile.checked)
messageInput.text = ""
fileMentionPopup.clearMentions()
scrollToBottom()
}
Dialog {
id: compressConfirmDialog
anchors.centerIn: parent
title: qsTr("Compress Chat")
modal: true
standardButtons: Dialog.Yes | Dialog.No
Label {
text: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.")
wrapMode: Text.WordWrap
}
onAccepted: root.compressCurrentChat()
}
Toast {
id: errorToast
z: 1000
color: Qt.rgba(0.8, 0.2, 0.2, 0.9)
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.9)
border.color: Qt.darker(infoToast.color, 1.3)
toastTextColor: "#FFFFFF"
}
ContextViewer {
id: contextViewer
width: Math.min(parent.width * 0.85, 800)
height: Math.min(parent.height * 0.85, 700)
x: (parent.width - width) / 2
y: (parent.height - height) / 2
baseSystemPrompt: root.baseSystemPrompt
currentAgentRole: root.currentAgentRole
currentAgentRoleDescription: root.currentAgentRoleDescription
currentAgentRoleSystemPrompt: root.currentAgentRoleSystemPrompt
activeRules: root.activeRules
activeRulesCount: root.activeRulesCount
onOpenSettings: root.openSettings()
onOpenAgentRolesSettings: root.openAgentRolesSettings()
onOpenRulesFolder: root.openRulesFolder()
onRefreshRules: root.refreshRules()
onRuleSelected: function(index) {
contextViewer.selectedRuleContent = root.getRuleContent(index)
}
}
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)
}
}
function onOpenFilesChanged() {
if (fileMentionPopup.visible)
Qt.callLater(fileMentionPopup.refreshSearch)
}
}
FileMentionPopup {
id: fileMentionPopup
z: 999
width: Math.min(480, root.width - 20)
x: Math.max(5, Math.min(view.x + 5, root.width - width - 5))
y: view.y - height - 4
onSelectionRequested: root.applyMentionSelection()
onFileAttachRequested: function(filePaths) {
root.addFilesToAttachList(filePaths)
}
}
SkillCommandPopup {
id: skillCommandPopup
z: 999
width: Math.min(480, root.width - 20)
x: Math.max(5, Math.min(view.x + 5, root.width - width - 5))
y: view.y - height - 4
skillProvider: root
onSelectionRequested: root.applySkillSelection()
}
Component.onCompleted: {
focusInput()
}
}

View File

@@ -1,407 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import ChatView
import QtQuick.Controls
import QtQuick.Layouts
import UIControls
Rectangle {
id: root
property alias msgModel: msgCreator.model
property alias messageAttachments: attachmentsModel.model
property alias messageImages: imagesModel.model
property string chatFilePath: ""
property string textFontFamily: Qt.application.font.family
property string codeFontFamily: {
switch (Qt.platform.os) {
case "windows":
return "Consolas";
case "osx":
return "Menlo";
case "linux":
return "DejaVu Sans Mono";
default:
return "monospace";
}
}
property int textFontSize: Qt.application.font.pointSize
property int codeFontSize: Qt.application.font.pointSize
property int textFormat: 0
property Flickable chatViewport: null
property bool isUserMessage: false
property int messageIndex: -1
property int promptTokens: 0
property int completionTokens: 0
property int cachedPromptTokens: 0
property int reasoningTokens: 0
signal resetChatToMessage(int index)
signal openFileRequested(string filePath)
height: msgColumn.implicitHeight + 10
radius: 8
color: isUserMessage ? palette.alternateBase
: palette.base
HoverHandler {
id: mouse
}
ColumnLayout {
id: msgColumn
x: 5
width: parent.width - x
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Repeater {
id: msgCreator
delegate: Loader {
id: msgCreatorDelegate
// Fix me:
// why does `required property MessagePart modelData` not work?
required property var modelData
Layout.preferredWidth: root.width
sourceComponent: {
// If `required property MessagePart modelData` is used
// and conversion to MessagePart fails, you're left
// with a nullptr. This tests that to prevent crashing.
if(!modelData) {
return undefined;
}
switch(modelData.type) {
case MessagePartType.Text: return textComponent;
case MessagePartType.Code: return codeBlockComponent;
default: return textComponent;
}
}
Component {
id: textComponent
TextComponent {
itemData: msgCreatorDelegate.modelData
}
}
Component {
id: codeBlockComponent
CodeBlockComponent {
itemData: msgCreatorDelegate.modelData
}
}
}
}
Flow {
id: attachmentsFlow
Layout.fillWidth: true
visible: attachmentsModel.model && attachmentsModel.model.length > 0
leftPadding: 10
rightPadding: 10
spacing: 5
Repeater {
id: attachmentsModel
delegate: AttachmentComponent {
required property int index
required property var modelData
itemData: modelData
}
}
}
Flow {
id: imagesFlow
Layout.fillWidth: true
visible: imagesModel.model && imagesModel.model.length > 0
leftPadding: 10
rightPadding: 10
spacing: 10
Repeater {
id: imagesModel
delegate: ImageComponent {
required property int index
required property var modelData
itemData: modelData
}
}
}
RowLayout {
id: usageBadge
Layout.fillWidth: true
Layout.leftMargin: 10
Layout.rightMargin: 10
spacing: 8
visible: !root.isUserMessage
&& (root.promptTokens > 0 || root.completionTokens > 0)
Item { Layout.fillWidth: true }
Text {
text: root.cachedPromptTokens > 0
? qsTr("↑ %1 (cached %2)").arg(root.promptTokens).arg(root.cachedPromptTokens)
: qsTr("↑ %1").arg(root.promptTokens)
color: palette.placeholderText
font.pointSize: Math.max(root.textFontSize - 2, 7)
}
Text {
text: root.reasoningTokens > 0
? qsTr("↓ %1 (reasoning %2)").arg(root.completionTokens).arg(root.reasoningTokens)
: qsTr("↓ %1").arg(root.completionTokens)
color: palette.placeholderText
font.pointSize: Math.max(root.textFontSize - 2, 7)
}
Text {
text: qsTr("Σ %1").arg(root.promptTokens + root.completionTokens)
color: palette.placeholderText
font.pointSize: Math.max(root.textFontSize - 2, 7)
}
}
}
Rectangle {
id: userMessageMarker
anchors.verticalCenter: parent.verticalCenter
width: 3
height: root.height - root.radius
color: "#92BD6C"
radius: root.radius
visible: root.isUserMessage
}
QoAButton {
id: stopButtonId
anchors {
right: parent.right
top: parent.top
}
icon {
source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg"
height: 15
width: 15
}
visible: root.isUserMessage && mouse.hovered
onClicked: function() {
root.resetChatToMessage(root.messageIndex)
}
QoAToolTip {
visible: stopButtonId.hovered
text: qsTr("Reset chat to this message and edit")
delay: 500
}
}
component TextComponent : TextBlock {
required property var itemData
height: implicitHeight + 10
verticalAlignment: Text.AlignVCenter
leftPadding: 10
text: textFormat == Text.MarkdownText ? utils.getSafeMarkdownText(itemData.text)
: itemData.text
font.family: root.textFontFamily
font.pointSize: root.textFontSize
textFormat: {
if (root.textFormat == 0) {
return Text.MarkdownText
} else if (root.textFormat == 1) {
return Text.RichText
} else {
return Text.PlainText
}
}
onLinkActivated: function(link) {
if (link.startsWith("file://")) {
var filePath = link.replace(/^file:\/\//, "")
root.openFileRequested(filePath)
} else {
Qt.openUrlExternally(link)
}
}
ChatUtils {
id: utils
}
}
component CodeBlockComponent : CodeBlock {
id: codeblock
required property var itemData
anchors {
left: parent.left
leftMargin: 10
right: parent.right
rightMargin: 10
}
code: itemData.text
language: itemData.language
codeFontFamily: root.codeFontFamily
codeFontSize: root.codeFontSize
viewport: root.chatViewport
}
component AttachmentComponent : Rectangle {
required property var itemData
height: attachFileText.implicitHeight + 8
width: attachFileText.implicitWidth + 16
radius: 4
color: attachFileMouseArea.containsMouse ? Qt.lighter(palette.button, 1.1) : palette.button
border.width: 1
border.color: palette.mid
Behavior on color { ColorAnimation { duration: 100 } }
FileItem {
id: fileItem
filePath: itemData.filePath || ""
}
Text {
id: attachFileText
anchors.centerIn: parent
text: (itemData.fileName || "")
color: palette.buttonText
font.pointSize: root.textFontSize - 1
}
MouseArea {
id: attachFileMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.modifiers & Qt.ShiftModifier) {
fileItem.openFileInExternalEditor()
} else {
fileItem.openFileInEditor()
}
}
QoAToolTip {
visible: attachFileMouseArea.containsMouse
text: qsTr("Click: Open in Qt Creator\nShift+Click: Open in System Editor")
delay: 500
}
}
}
component ImageComponent : Rectangle {
required property var itemData
readonly property int maxImageWidth: Math.min(400, root.width - 40)
readonly property int maxImageHeight: 300
width: Math.min(imageDisplay.implicitWidth, maxImageWidth) + 16
height: imageDisplay.implicitHeight + fileNameText.implicitHeight + 16
radius: 4
color: imageMouseArea.containsMouse ? Qt.lighter(palette.base, 1.05) : palette.base
border.width: 1
border.color: palette.mid
Behavior on color { ColorAnimation { duration: 100 } }
FileItem {
id: imageFileItem
filePath: itemData.filePath || ""
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 4
Image {
id: imageDisplay
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: parent.parent.maxImageWidth
Layout.maximumHeight: parent.parent.maxImageHeight
source: itemData.imageUrl ? itemData.imageUrl : ""
sourceSize.width: parent.parent.maxImageWidth
sourceSize.height: parent.parent.maxImageHeight
fillMode: Image.PreserveAspectFit
cache: true
asynchronous: true
smooth: true
mipmap: true
BusyIndicator {
anchors.centerIn: parent
running: imageDisplay.status === Image.Loading
visible: running
}
Text {
anchors.centerIn: parent
text: qsTr("Failed to load image")
visible: imageDisplay.status === Image.Error
color: palette.placeholderText
}
}
Text {
id: fileNameText
Layout.fillWidth: true
text: itemData.fileName || ""
color: palette.text
font.pointSize: root.textFontSize - 1
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
}
}
MouseArea {
id: imageMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.modifiers & Qt.ShiftModifier) {
imageFileItem.openFileInExternalEditor()
} else {
imageFileItem.openFileInEditor()
}
}
QoAToolTip {
visible: imageMouseArea.containsMouse
text: qsTr("Click: Open in Qt Creator\nShift+Click: Open in System Editor")
delay: 500
}
}
}
}

View File

@@ -1,167 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
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
property Flickable viewport: null
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
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 {
left: parent.left
right: parent.right
top: header.bottom
margins: 10
}
text: root.code
readOnly: true
selectByMouse: true
color: parent.color.hslLightness > 0.5 ? "black" : "white"
wrapMode: Text.WordWrap
selectionColor: palette.highlight
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: contextMenu.open()
}
}
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 {
id: copyButton
anchors.right: parent.right
anchors.rightMargin: 5
y: {
if (!root.expanded || !root.viewport)
return 5
const flick = root.viewport
const topInContent = root.mapToItem(flick.contentItem, 0, 0).y
const topInView = topInContent - flick.contentY
const desired = topInView < 0 ? (-topInView + 5) : 5
const maxY = Math.max(5, root.height - copyButton.height - 5)
return Math.max(5, Math.min(desired, maxY))
}
text: qsTr("Copy")
onClicked: {
utils.copyToClipboard(root.code)
text = qsTr("Copied")
copyTimer.start()
}
Timer {
id: copyTimer
interval: 2000
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

@@ -1,453 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import UIControls
import ChatView
import Qt.labs.platform as Platform
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
ChatUtils {
id: utils
}
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
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
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: oldConentContextMenu.open()
}
}
Platform.Menu {
id: oldConentContextMenu
Platform.MenuItem {
text: qsTr("Copy")
onTriggered: {
const textToCopy = oldContentText.selectedText || root.oldContent
utils.copyToClipboard(textToCopy)
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: fileEditView.expanded ? qsTr("Collapse") : qsTr("Expand")
onTriggered: fileEditView.expanded = !fileEditView.expanded
}
}
}
}
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
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
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: newContentContextMenu.open()
}
}
Platform.Menu {
id: newContentContextMenu
Platform.MenuItem {
text: qsTr("Copy")
onTriggered: {
const textToCopy = newContentText.selectedText || root.newContent
utils.copyToClipboard(textToCopy)
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: fileEditView.expanded ? qsTr("Collapse") : qsTr("Expand")
onTriggered: fileEditView.expanded = !fileEditView.expanded
}
}
}
}
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

@@ -1,38 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import Qt.labs.platform as Platform
TextEdit {
id: root
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
selectionColor: palette.highlight
color: palette.text
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: contextMenu.open()
cursorShape: root.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
}
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

@@ -1,167 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import Qt.labs.platform as Platform
Rectangle {
id: root
property string thinkingContent: ""
// property string signature: ""
property bool isRedacted: false
property bool expanded: false
property alias headerOpacity: headerRow.opacity
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: root.isRedacted ? qsTr("Thinking (Redacted)")
: qsTr("Thinking")
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
Text {
visible: root.isRedacted
width: parent.width
text: qsTr("Thinking content was redacted by safety systems")
font.pixelSize: 11
font.italic: true
color: Qt.rgba(0.8, 0.4, 0.4, 1.0)
wrapMode: Text.WordWrap
}
TextEdit {
id: thinkingText
visible: !root.isRedacted
width: parent.width
text: root.thinkingContent
readOnly: true
selectByMouse: true
color: palette.text
wrapMode: Text.WordWrap
font.family: "monospace"
font.pixelSize: 11
selectionColor: palette.highlight
}
// Rectangle {
// visible: root.signature.length > 0 && root.expanded
// width: parent.width
// height: signatureText.height + 10
// color: palette.alternateBase
// radius: 4
// Text {
// id: signatureText
// anchors {
// left: parent.left
// right: parent.right
// verticalCenter: parent.verticalCenter
// margins: 5
// }
// text: qsTr("Signature: %1").arg(root.signature.substring(0, Math.min(40, root.signature.length)) + "...")
// font.pixelSize: 9
// font.family: "monospace"
// color: palette.mid
// elide: Text.ElideRight
// }
// }
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: contextMenu.open()
propagateComposedEvents: true
}
Platform.Menu {
id: contextMenu
Platform.MenuItem {
text: root.expanded ? qsTr("Collapse") : qsTr("Expand")
onTriggered: root.expanded = !root.expanded
}
}
Rectangle {
id: thinkingMarker
anchors.verticalCenter: parent.verticalCenter
width: 3
height: root.height - root.radius
color: root.isRedacted ? Qt.rgba(0.8, 0.3, 0.3, 0.9)
: (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

@@ -1,146 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import Qt.labs.platform as Platform
Rectangle {
id: root
property string toolContent: ""
property bool expanded: false
property alias headerOpacity: headerRow.opacity
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

@@ -1,145 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ChatView
import UIControls
Flow {
id: root
property alias attachedFilesModel: attachRepeater.model
property color accentColor: palette.mid
property string iconPath
signal removeFileFromListByIndex(index: int)
spacing: 5
leftPadding: 5
rightPadding: 5
topPadding: attachRepeater.model.length > 0 ? 2 : 0
bottomPadding: attachRepeater.model.length > 0 ? 2 : 0
Repeater {
id: attachRepeater
delegate: FileItem {
id: fileItem
required property int index
required property string modelData
filePath: modelData
height: 30
width: contentRow.width + 10
Rectangle {
anchors.fill: parent
radius: 4
color: palette.button
border.width: 1
border.color: mouse.containsMouse ? palette.highlight : root.accentColor
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
contextMenu.popup()
} else if (mouse.button === Qt.MiddleButton ||
(mouse.button === Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier))) {
root.removeFileFromListByIndex(fileItem.index)
} else if (mouse.modifiers & Qt.ShiftModifier) {
fileItem.openFileInExternalEditor()
} else {
fileItem.openFileInEditor()
}
}
QoAToolTip {
visible: mouse.containsMouse
delay: 500
text: "Click: Open in Qt Creator\nShift+Click: Open in external editor\nCtrl+Click / Middle Click: Remove"
}
}
Menu {
id: contextMenu
MenuItem {
text: "Open in Qt Creator"
onTriggered: fileItem.openFileInEditor()
}
MenuItem {
text: "Open in External Editor"
onTriggered: fileItem.openFileInExternalEditor()
}
MenuSeparator {}
MenuItem {
text: "Remove"
onTriggered: root.removeFileFromListByIndex(fileItem.index)
}
}
Row {
id: contentRow
spacing: 5
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 5
Image {
id: icon
anchors.verticalCenter: parent.verticalCenter
source: root.iconPath
sourceSize.width: 8
sourceSize.height: 15
}
Text {
id: fileNameText
anchors.verticalCenter: parent.verticalCenter
color: palette.buttonText
text: {
const parts = modelData.split('/');
return parts[parts.length - 1];
}
}
MouseArea {
id: closeButton
anchors.verticalCenter: parent.verticalCenter
width: closeIcon.width + 5
height: closeButton.width + 5
onClicked: root.removeFileFromListByIndex(index)
Image {
id: closeIcon
anchors.centerIn: parent
source: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/close-dark.svg"
: "qrc:/qt/qml/ChatView/icons/close-light.svg"
width: 6
height: 6
}
}
}
}
}
}

View File

@@ -1,153 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ChatView
import UIControls
Rectangle {
id: root
property alias sendButton: sendButtonId
property alias syncOpenFiles: syncOpenFilesId
property alias attachFiles: attachFilesId
property alias attachImages: attachImagesId
property alias linkFiles: linkFilesId
property alias compressButton: compressButtonId
property alias cancelCompressButton: cancelCompressButtonId
property bool isCompressing: false
color: palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.1) :
Qt.lighter(palette.window, 1.1)
RowLayout {
id: bottomBar
anchors {
left: parent.left
leftMargin: 5
right: parent.right
rightMargin: 5
verticalCenter: parent.verticalCenter
}
spacing: 10
QoAButton {
id: attachFilesId
icon {
source: "qrc:/qt/qml/ChatView/icons/attach-file-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Attach file to message")
}
QoAButton {
id: attachImagesId
icon {
source: "qrc:/qt/qml/ChatView/icons/image-dark.svg"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Attach image to message")
}
QoAButton {
id: linkFilesId
icon {
source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Link file to context")
}
CheckBox {
id: syncOpenFilesId
text: qsTr("Sync open files")
ToolTip.visible: syncOpenFilesId.hovered
ToolTip.text: qsTr("Automatically synchronize currently opened files with the model context")
}
Item {
Layout.fillWidth: true
}
Row {
id: compressingRow
visible: root.isCompressing
spacing: 6
BusyIndicator {
id: compressBusyIndicator
anchors.verticalCenter: parent.verticalCenter
running: root.isCompressing
width: 16
height: 16
}
Text {
text: qsTr("Compressing...")
anchors.verticalCenter: parent.verticalCenter
color: palette.text
font.pixelSize: 12
}
QoAButton {
id: cancelCompressButtonId
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Cancel")
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Cancel compression")
}
}
QoAButton {
id: compressButtonId
visible: !root.isCompressing
text: qsTr("Compress")
icon {
source: "qrc:/qt/qml/ChatView/icons/compress-icon.svg"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Compress chat (create summarized copy using LLM)")
}
QoAButton {
id: sendButtonId
icon {
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
}
}
}

View File

@@ -1,542 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Controls.Basic as QQC
import UIControls
import ChatView
Popup {
id: root
property string baseSystemPrompt
property string currentAgentRole
property string currentAgentRoleDescription
property string currentAgentRoleSystemPrompt
property var activeRules
property int activeRulesCount
property string selectedRuleContent
signal openSettings()
signal openAgentRolesSettings()
signal openRulesFolder()
signal refreshRules()
signal ruleSelected(int index)
modal: true
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: palette.window
border.color: palette.mid
border.width: 1
radius: 4
}
ChatUtils {
id: utils
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 10
Text {
text: qsTr("Chat Context")
font.pixelSize: 16
font.bold: true
color: palette.text
Layout.fillWidth: true
}
QoAButton {
text: qsTr("Refresh")
onClicked: root.refreshRules()
}
QoAButton {
text: qsTr("Close")
onClicked: root.close()
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: palette.mid
}
Flickable {
id: mainFlickable
Layout.fillWidth: true
Layout.fillHeight: true
contentHeight: sectionsColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: sectionsColumn
width: mainFlickable.width
spacing: 8
CollapsibleSection {
id: systemPromptSection
Layout.fillWidth: true
title: qsTr("Base System Prompt")
badge: root.baseSystemPrompt.length > 0 ? qsTr("Active") : qsTr("Empty")
badgeColor: root.baseSystemPrompt.length > 0 ? Qt.rgba(0.2, 0.6, 0.3, 1.0) : palette.mid
sectionContent: ColumnLayout {
spacing: 5
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(Math.max(systemPromptText.implicitHeight + 16, 50), 200)
color: palette.base
border.color: palette.mid
border.width: 1
radius: 2
Flickable {
id: systemPromptFlickable
anchors.fill: parent
anchors.margins: 8
contentHeight: systemPromptText.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
TextEdit {
id: systemPromptText
width: systemPromptFlickable.width
text: root.baseSystemPrompt.length > 0 ? root.baseSystemPrompt : qsTr("No system prompt configured")
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
color: root.baseSystemPrompt.length > 0 ? palette.text : palette.mid
font.family: "monospace"
font.pixelSize: 11
}
QQC.ScrollBar.vertical: QQC.ScrollBar {
policy: systemPromptFlickable.contentHeight > systemPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
}
}
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
QoAButton {
text: qsTr("Copy")
enabled: root.baseSystemPrompt.length > 0
onClicked: utils.copyToClipboard(root.baseSystemPrompt)
}
QoAButton {
text: qsTr("Edit in Settings")
onClicked: {
root.openSettings()
root.close()
}
}
}
}
}
CollapsibleSection {
id: agentRoleSection
Layout.fillWidth: true
title: qsTr("Agent Role")
badge: root.currentAgentRole
badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid
sectionContent: ColumnLayout {
spacing: 8
Text {
text: root.currentAgentRoleDescription
font.pixelSize: 11
font.italic: true
color: palette.mid
wrapMode: Text.WordWrap
Layout.fillWidth: true
visible: root.currentAgentRoleDescription.length > 0
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200)
color: palette.base
border.color: palette.mid
border.width: 1
radius: 2
visible: root.currentAgentRoleSystemPrompt.length > 0
Flickable {
id: agentPromptFlickable
anchors.fill: parent
anchors.margins: 8
contentHeight: agentPromptText.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
TextEdit {
id: agentPromptText
width: agentPromptFlickable.width
text: root.currentAgentRoleSystemPrompt
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
color: palette.text
font.family: "monospace"
font.pixelSize: 11
}
QQC.ScrollBar.vertical: QQC.ScrollBar {
policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
}
}
}
Text {
text: qsTr("No role selected. Using base system prompt only.")
font.pixelSize: 11
color: palette.mid
wrapMode: Text.WordWrap
Layout.fillWidth: true
visible: root.currentAgentRoleSystemPrompt.length === 0
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
QoAButton {
text: qsTr("Copy")
enabled: root.currentAgentRoleSystemPrompt.length > 0
onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt)
}
QoAButton {
text: qsTr("Manage Roles")
onClicked: {
root.openAgentRolesSettings()
root.close()
}
}
}
}
}
CollapsibleSection {
id: projectRulesSection
Layout.fillWidth: true
title: qsTr("Project Rules")
badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None")
badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid
sectionContent: ColumnLayout {
spacing: 8
SplitView {
Layout.fillWidth: true
Layout.preferredHeight: 220
orientation: Qt.Horizontal
visible: root.activeRulesCount > 0
Rectangle {
SplitView.minimumWidth: 120
SplitView.preferredWidth: 180
color: palette.base
border.color: palette.mid
border.width: 1
radius: 2
ColumnLayout {
anchors.fill: parent
anchors.margins: 5
spacing: 5
Text {
text: qsTr("Rules (%1)").arg(rulesList.count)
font.pixelSize: 11
font.bold: true
color: palette.text
Layout.fillWidth: true
}
ListView {
id: rulesList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: root.activeRules
currentIndex: 0
boundsBehavior: Flickable.StopAtBounds
delegate: ItemDelegate {
required property var modelData
required property int index
width: ListView.view.width
height: ruleItemContent.implicitHeight + 8
highlighted: ListView.isCurrentItem
background: Rectangle {
color: {
if (parent.highlighted)
return palette.highlight
if (parent.hovered)
return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05))
return "transparent"
}
radius: 2
}
contentItem: ColumnLayout {
id: ruleItemContent
spacing: 2
Text {
text: modelData.fileName
font.pixelSize: 10
color: parent.parent.highlighted ? palette.highlightedText : palette.text
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Text {
text: modelData.category
font.pixelSize: 9
color: parent.parent.highlighted ? palette.highlightedText : palette.mid
Layout.fillWidth: true
}
}
onClicked: {
rulesList.currentIndex = index
root.ruleSelected(index)
}
}
QQC.ScrollBar.vertical: QQC.ScrollBar {
policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
}
}
}
}
Rectangle {
SplitView.fillWidth: true
SplitView.minimumWidth: 200
color: palette.base
border.color: palette.mid
border.width: 1
radius: 2
ColumnLayout {
anchors.fill: parent
anchors.margins: 5
spacing: 5
RowLayout {
Layout.fillWidth: true
spacing: 5
Text {
text: qsTr("Content")
font.pixelSize: 11
font.bold: true
color: palette.text
Layout.fillWidth: true
}
QoAButton {
text: qsTr("Copy")
enabled: root.selectedRuleContent.length > 0
onClicked: utils.copyToClipboard(root.selectedRuleContent)
}
}
Flickable {
id: ruleContentFlickable
Layout.fillWidth: true
Layout.fillHeight: true
contentHeight: ruleContentArea.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
TextEdit {
id: ruleContentArea
width: ruleContentFlickable.width
text: root.selectedRuleContent
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
selectionColor: palette.highlight
color: palette.text
font.family: "monospace"
font.pixelSize: 11
}
QQC.ScrollBar.vertical: QQC.ScrollBar {
policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
}
}
}
}
}
Text {
text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/")
font.pixelSize: 11
color: palette.mid
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: root.activeRulesCount === 0
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
QoAButton {
text: qsTr("Open Rules Folder")
onClicked: root.openRulesFolder()
}
}
}
}
}
QQC.ScrollBar.vertical: QQC.ScrollBar {
policy: mainFlickable.contentHeight > mainFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: palette.mid
}
Text {
text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files")
font.pixelSize: 9
color: palette.mid
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
component CollapsibleSection: ColumnLayout {
id: sectionRoot
property string title
property string badge
property color badgeColor: palette.mid
property Component sectionContent: null
property bool expanded: false
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 32
color: sectionMouseArea.containsMouse ? Qt.tint(palette.button, Qt.rgba(0, 0, 0, 0.05)) : palette.button
border.color: palette.mid
border.width: 1
radius: 2
MouseArea {
id: sectionMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: sectionRoot.expanded = !sectionRoot.expanded
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
Text {
text: sectionRoot.expanded ? "▼" : "▶"
font.pixelSize: 10
color: palette.text
}
Text {
text: sectionRoot.title
font.pixelSize: 12
font.bold: true
color: palette.text
Layout.fillWidth: true
}
Rectangle {
implicitWidth: badgeText.implicitWidth + 12
implicitHeight: 18
color: sectionRoot.badgeColor
radius: 3
Text {
id: badgeText
anchors.centerIn: parent
text: sectionRoot.badge
font.pixelSize: 10
color: "#FFFFFF"
}
}
}
}
Loader {
id: contentLoader
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.topMargin: 8
Layout.bottomMargin: 4
sourceComponent: sectionRoot.sectionContent
visible: sectionRoot.expanded
active: sectionRoot.expanded
}
}
onOpened: {
if (root.activeRulesCount > 0) {
root.ruleSelected(0)
}
}
}

View File

@@ -1,145 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
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

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

View File

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

View File

@@ -1,275 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
Item {
id: root
signal filesDroppedToAttach(var urlStrings)
signal filesDroppedToLink(var urlStrings)
property string activeZone: ""
property int filesCount: 0
property bool isDragActive: false
Item {
id: splitDropOverlay
anchors.fill: parent
visible: false
z: 999
opacity: 0
Behavior on opacity {
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
}
Rectangle {
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
topMargin: 30
}
width: fileCountText.width + 40
height: 50
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
radius: 25
visible: root.filesCount > 0
Text {
id: fileCountText
anchors.centerIn: parent
text: qsTr("%n file(s) to drop", "", root.filesCount)
font.pixelSize: 16
font.bold: true
color: palette.highlightedText
}
}
Rectangle {
id: leftZone
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
color: root.activeZone === "left"
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
border.width: root.activeZone === "left" ? 3 : 2
border.color: root.activeZone === "left"
? palette.highlight
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
Column {
anchors.centerIn: parent
spacing: 15
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Attach")
font.pixelSize: 24
font.bold: true
color: root.activeZone === "left" ? palette.highlightedText : palette.text
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Images & Text Files")
font.pixelSize: 14
color: root.activeZone === "left" ? palette.highlightedText : palette.text
opacity: 0.8
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("(for one-time use)")
font.pixelSize: 12
font.italic: true
color: root.activeZone === "left" ? palette.highlightedText : palette.text
opacity: 0.6
}
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
Rectangle {
id: rightZone
anchors {
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
color: root.activeZone === "right"
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
border.width: root.activeZone === "right" ? 3 : 2
border.color: root.activeZone === "right"
? palette.highlight
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
Column {
anchors.centerIn: parent
spacing: 15
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("LINK")
font.pixelSize: 24
font.bold: true
color: root.activeZone === "right" ? palette.highlightedText : palette.text
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Text Files")
font.pixelSize: 14
color: root.activeZone === "right" ? palette.highlightedText : palette.text
opacity: 0.8
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("(added to context)")
font.pixelSize: 12
font.italic: true
color: root.activeZone === "right" ? palette.highlightedText : palette.text
opacity: 0.6
}
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
Rectangle {
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
bottom: parent.bottom
}
width: 2
color: palette.mid
opacity: 0.4
}
MouseArea {
id: leftDropArea
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
hoverEnabled: true
onEntered: {
root.activeZone = "left"
}
}
MouseArea {
id: rightDropArea
anchors {
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
hoverEnabled: true
onEntered: {
root.activeZone = "right"
}
}
}
DropArea {
id: globalDropArea
anchors.fill: parent
onEntered: (drag) => {
if (drag.hasUrls) {
root.isDragActive = true
root.filesCount = drag.urls.length
splitDropOverlay.visible = true
splitDropOverlay.opacity = 1
root.activeZone = ""
}
}
onExited: {
root.isDragActive = false
root.filesCount = 0
splitDropOverlay.opacity = 0
Qt.callLater(function() {
if (!root.isDragActive) {
splitDropOverlay.visible = false
root.activeZone = ""
}
})
}
onPositionChanged: (drag) => {
if (drag.hasUrls) {
root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
}
}
onDropped: (drop) => {
const targetZone = root.activeZone
root.isDragActive = false
root.filesCount = 0
splitDropOverlay.opacity = 0
Qt.callLater(function() {
splitDropOverlay.visible = false
root.activeZone = ""
})
if (!drop.hasUrls || drop.urls.length === 0) {
return
}
var urlStrings = []
for (var i = 0; i < drop.urls.length; i++) {
var urlString = drop.urls[i].toString()
if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
urlStrings.push(urlString)
}
}
if (urlStrings.length === 0) {
return
}
drop.accept(Qt.CopyAction)
if (targetZone === "right") {
root.filesDroppedToLink(urlStrings)
} else {
root.filesDroppedToAttach(urlStrings)
}
}
}
}

View File

@@ -1,87 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
Rectangle {
id: root
property alias toastTextItem: textItem
property alias toastTextColor: textItem.color
property string errorText: ""
property int displayDuration: 7000
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

@@ -1,321 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import ChatView
import UIControls
Rectangle {
id: root
property bool isInEditor: false
property alias saveButton: saveButtonId
property alias loadButton: loadButtonId
property alias clearButton: clearButtonId
property alias newChatButton: newChatButtonId
property alias tokensBadge: tokensBadgeId
property alias recentPath: recentPathId
property alias openChatHistory: openChatHistoryId
property alias pinButton: pinButtonId
property alias relocateButton: relocateButtonId
property alias contextButton: contextButtonId
property alias toolsButton: toolsButtonId
property alias thinkingMode: thinkingModeId
property alias settingsButton: settingsButtonId
property alias configSelector: configSelectorId
property alias roleSelector: roleSelector
color: palette.window.hslLightness > 0.5 ?
Qt.darker(palette.window, 1.1) :
Qt.lighter(palette.window, 1.1)
Flow {
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
margins: 5
}
spacing: 10
Row {
id: firstRow
spacing: 10
QoAButton {
id: pinButtonId
anchors.verticalCenter: parent.verticalCenter
checkable: true
icon {
source: checked ? "qrc:/qt/qml/ChatView/icons/window-lock.svg"
: "qrc:/qt/qml/ChatView/icons/window-unlock.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: checked ? qsTr("Unpin chat window")
: qsTr("Pin chat window to the top")
}
QoAButton {
id: relocateButtonId
anchors.verticalCenter: parent.verticalCenter
icon {
source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
}
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
QoAButton {
id: clearButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
height: 15
width: 8
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Clean chat")
}
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
QoAButton {
id: newChatButtonId
visible: root.isInEditor
icon {
source: "qrc:/qt/qml/ChatView/icons/new-chat-icon.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Open new chat in a new tab")
}
QoAComboBox {
id: configSelectorId
implicitHeight: 25
model: []
currentIndex: 0
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Switch saved AI configuration")
}
QoAComboBox {
id: roleSelector
implicitHeight: 25
model: []
currentIndex: 0
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Switch agent role (different system prompts)")
}
}
Row {
spacing: 10
QoAButton {
id: toolsButtonId
anchors.verticalCenter: parent.verticalCenter
checkable: true
opacity: enabled ? 1.0 : 0.2
icon {
source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg"
: "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: {
if (!toolsButtonId.enabled) {
return qsTr("Tools are disabled in General Settings")
}
return checked
? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
: qsTr("Tools disabled: Simple conversation without tool access")
}
}
QoAButton {
id: thinkingModeId
anchors.verticalCenter: parent.verticalCenter
checkable: true
opacity: enabled ? 1.0 : 0.2
icon {
source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg"
: "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: enabled ? (checked ? qsTr("Thinking Mode enabled (Check model list support it)")
: qsTr("Thinking Mode disabled"))
: qsTr("Thinking Mode is not available for this provider")
}
QoAButton {
id: settingsButtonId
anchors.verticalCenter: parent.verticalCenter
icon {
source: "qrc:/qt/qml/ChatView/icons/settings-icon.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Open Chat Assistant Settings")
}
QoASeparator {
anchors.verticalCenter: parent.verticalCenter
}
}
Item {
height: firstRow.height
width: recentPathId.width
Text {
id: recentPathId
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, root.width)
elide: Text.ElideMiddle
color: palette.text
font.pixelSize: 12
MouseArea {
anchors.fill: parent
hoverEnabled: true
ToolTip.visible: containsMouse
ToolTip.delay: 500
ToolTip.text: recentPathId.text
}
}
}
RowLayout {
id: secondRow
Layout.preferredWidth: root.width
Layout.preferredHeight: firstRow.height
spacing: 10
QoAButton {
id: saveButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/save-chat-dark.svg"
height: 15
width: 8
}
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: 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")
}
QoASeparator {}
QoAButton {
id: contextButtonId
icon {
source: "qrc:/qt/qml/ChatView/icons/context-icon.svg"
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
height: 15
width: 15
}
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("View chat context (system prompt, role, rules)")
}
Badge {
id: tokensBadgeId
ToolTip.visible: hovered
ToolTip.delay: 250
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
}
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2024 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 ChatView
Rectangle {
id: root
property string code: ""
property string language: ""
property color selectionColor
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";
}
}
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
ChatUtils {
id: utils
}
TextEdit {
id: codeText
anchors.fill: parent
anchors.margins: 10
text: root.code
readOnly: true
selectByMouse: true
font.family: root.monospaceFont
font.pointSize: 12
color: parent.color.hslLightness > 0.5 ? "black" : "white"
wrapMode: Text.WordWrap
selectionColor: root.selectionColor
}
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
}
Button {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 5
text: "Copy"
onClicked: {
utils.copyToClipboard(root.code)
text = qsTr("Copied")
copyTimer.start()
}
Timer {
id: copyTimer
interval: 2000
onTriggered: parent.text = qsTr("Copy")
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2024 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
TextEdit {
id: root
readOnly: true
selectByMouse: true
wrapMode: Text.WordWrap
textFormat: Text.StyledText
}

View File

@@ -1,231 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// Copyright (C) 2025 Povilas Kanapickas <povilas@radix.lt>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "CodeHandler.hpp"
#include <settings/CodeCompletionSettings.hpp>
#include <QFileInfo>
#include <QHash>
namespace QodeAssist {
struct LanguageProperties
{
QString name;
QString commentStyle;
QVector<QString> namesFromModel;
QVector<QString> fileExtensions;
};
const QVector<LanguageProperties> customLanguagesFromSettings()
{
QVector<LanguageProperties> customLanguages;
const QStringList customLanguagesList = Settings::codeCompletionSettings().customLanguages();
for (const QString &entry : customLanguagesList) {
if (entry.trimmed().isEmpty()) {
continue;
}
QStringList parts = entry.split(',');
if (parts.size() < 4) {
continue;
}
QString name = parts[0].trimmed();
QString commentStyle = parts[1].trimmed();
QStringList modelNamesList = parts[2].trimmed().split(' ', Qt::SkipEmptyParts);
QStringList extensionsList = parts[3].trimmed().split(' ', Qt::SkipEmptyParts);
if (!name.isEmpty() && !commentStyle.isEmpty() && !modelNamesList.isEmpty()
&& !extensionsList.isEmpty()) {
QVector<QString> modelNames;
for (const auto &modelName : modelNamesList) {
modelNames.append(modelName);
}
QVector<QString> extensions;
for (const auto &ext : extensionsList) {
extensions.append(ext);
}
customLanguages.append({name, commentStyle, modelNames, extensions});
}
}
return customLanguages;
}
const QVector<LanguageProperties> &getKnownLanguages()
{
static QVector<LanguageProperties> knownLanguages = {
{"python", "#", {"python", "py"}, {"py"}},
{"lua", "--", {"lua"}, {"lua"}},
{"js", "//", {"js", "javascript"}, {"js", "jsx"}},
{"ts", "//", {"ts", "typescript"}, {"ts", "tsx"}},
{"c-like", "//", {"c", "c++", "cpp"}, {"c", "h", "cpp", "hpp"}},
{"java", "//", {"java"}, {"java"}},
{"c#", "//", {"cs", "csharp"}, {"cs"}},
{"php", "//", {"php"}, {"php"}},
{"ruby", "#", {"rb", "ruby"}, {"rb"}},
{"go", "//", {"go"}, {"go"}},
{"swift", "//", {"swift"}, {"swift"}},
{"kotlin", "//", {"kt", "kotlin"}, {"kt", "kotlin"}},
{"scala", "//", {"scala"}, {"scala"}},
{"r", "#", {"r"}, {"r"}},
{"shell", "#", {"shell", "bash", "sh"}, {"sh", "bash"}},
{"perl", "#", {"pl", "perl"}, {"pl"}},
{"hs", "--", {"hs", "haskell"}, {"hs"}},
{"qml", "//", {"qml"}, {"qml"}},
};
knownLanguages.append(customLanguagesFromSettings());
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;
for (const auto &languageProps : getKnownLanguages()) {
result[languageProps.name] = languageProps.commentStyle;
}
return result;
}
static QHash<QString, QString> buildExtensionToLanguageMap()
{
QHash<QString, QString> result;
for (const auto &languageProps : getKnownLanguages()) {
for (const auto &extension : languageProps.fileExtensions) {
result[extension] = languageProps.name;
}
}
return result;
}
static QHash<QString, QString> buildModelLanguageNameToLanguageMap()
{
QHash<QString, QString> result;
for (const auto &languageProps : getKnownLanguages()) {
for (const auto &nameFromModel : languageProps.namesFromModel) {
result[nameFromModel] = languageProps.name;
}
}
return result;
}
QString CodeHandler::processText(QString text, QString currentFilePath)
{
QString result;
QStringList lines = text.split('\n');
bool inCodeBlock = false;
QString pendingComments;
auto currentFileExtension = QFileInfo(currentFilePath).suffix();
auto currentLanguage = detectLanguageFromExtension(currentFileExtension);
auto addPendingCommentsIfAny = [&]() {
if (pendingComments.isEmpty()) {
return;
}
QStringList commentLines = pendingComments.split('\n');
QString commentPrefix = getCommentPrefix(currentLanguage);
for (const QString &commentLine : commentLines) {
if (!commentLine.trimmed().isEmpty()) {
result += commentPrefix + " " + commentLine.trimmed() + "\n";
} else {
result += "\n";
}
}
pendingComments.clear();
};
for (const QString &line : lines) {
if (line.trimmed().startsWith("```")) {
if (!inCodeBlock) {
auto lineLanguage = detectLanguageFromLine(line);
if (!lineLanguage.isEmpty()) {
currentLanguage = lineLanguage;
}
addPendingCommentsIfAny();
if (lineLanguage.isEmpty()) {
// language not detected, so add direct output from model, if any
result += line.trimmed().mid(3) + "\n"; // add the remainder of line after ```
}
}
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) {
result += line + "\n";
} else {
QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
pendingComments += trimmed + "\n";
} else {
pendingComments += "\n";
}
}
}
addPendingCommentsIfAny();
return result;
}
QString CodeHandler::getCommentPrefix(const QString &language)
{
static const auto commentPrefixes = buildLanguageToCommentPrefixMap();
return commentPrefixes.value(language, "//");
}
QString CodeHandler::detectLanguageFromLine(const QString &line)
{
static const auto modelNameToLanguage = buildModelLanguageNameToLanguageMap();
return modelNameToLanguage.value(line.trimmed().mid(3).trimmed(), "");
}
QString CodeHandler::detectLanguageFromExtension(const QString &extension)
{
static const auto extensionToLanguage = buildExtensionToLanguageMap();
return extensionToLanguage.value(extension.toLower(), "");
}
const QRegularExpression &CodeHandler::getFullCodeBlockRegex()
{
static const QRegularExpression
regex(R"(```[\w\s]*\n([\s\S]*?)```)", QRegularExpression::MultilineOption);
return regex;
}
const QRegularExpression &CodeHandler::getPartialStartBlockRegex()
{
static const QRegularExpression
regex(R"(```[\w\s]*\n([\s\S]*?)$)", QRegularExpression::MultilineOption);
return regex;
}
const QRegularExpression &CodeHandler::getPartialEndBlockRegex()
{
static const QRegularExpression regex(R"(^([\s\S]*?)```)", QRegularExpression::MultilineOption);
return regex;
}
} // namespace QodeAssist

View File

@@ -1,40 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QRegularExpression>
#include <QString>
namespace QodeAssist {
class CodeHandler
{
public:
static QString processText(QString text, QString currentFileName);
/**
* Detects language from line, or returns empty string if this was not possible
*/
static QString detectLanguageFromLine(const QString &line);
/**
* Detects language file name, or returns empty string if this was not possible
*/
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);
static const QRegularExpression &getFullCodeBlockRegex();
static const QRegularExpression &getPartialStartBlockRegex();
static const QRegularExpression &getPartialEndBlockRegex();
};
} // namespace QodeAssist

View File

@@ -1,10 +1,26 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "ConfigurationManager.hpp"
#include <settings/ButtonAspect.hpp>
#include <QTimer>
#include <settings/ButtonAspect.hpp>
#include "QodeAssisttr.h"
@@ -19,59 +35,13 @@ ConfigurationManager &ConfigurationManager::instance()
void ConfigurationManager::init()
{
setupConnections();
updateAllTemplateDescriptions();
checkAllTemplate();
}
void ConfigurationManager::updateTemplateDescription(const Utils::StringAspect &templateAspect)
{
PluginLLMCore::PromptTemplate *templ = m_templateManger.getFimTemplateByName(templateAspect.value());
if (!templ) {
return;
}
if (&templateAspect == &m_generalSettings.ccTemplate) {
m_generalSettings.ccTemplateDescription.setValue(templ->description());
} else if (&templateAspect == &m_generalSettings.caTemplate) {
m_generalSettings.caTemplateDescription.setValue(templ->description());
} else if (&templateAspect == &m_generalSettings.qrTemplate) {
m_generalSettings.qrTemplateDescription.setValue(templ->description());
}
}
void ConfigurationManager::updateAllTemplateDescriptions()
{
updateTemplateDescription(m_generalSettings.ccTemplate);
updateTemplateDescription(m_generalSettings.caTemplate);
updateTemplateDescription(m_generalSettings.qrTemplate);
}
void ConfigurationManager::checkTemplate(const Utils::StringAspect &templateAspect)
{
PluginLLMCore::PromptTemplate *templ = m_templateManger.getFimTemplateByName(templateAspect.value());
if (templ->name() == templateAspect.value())
return;
if (&templateAspect == &m_generalSettings.ccTemplate) {
m_generalSettings.ccTemplate.setValue(templ->name());
} else if (&templateAspect == &m_generalSettings.caTemplate) {
m_generalSettings.caTemplate.setValue(templ->name());
}
}
void ConfigurationManager::checkAllTemplate()
{
checkTemplate(m_generalSettings.ccTemplate);
checkTemplate(m_generalSettings.caTemplate);
}
ConfigurationManager::ConfigurationManager(QObject *parent)
: QObject(parent)
, m_generalSettings(Settings::generalSettings())
, m_providersManager(PluginLLMCore::ProvidersManager::instance())
, m_templateManger(PluginLLMCore::PromptTemplateManager::instance())
, m_providersManager(LLMCore::ProvidersManager::instance())
, m_templateManger(LLMCore::PromptTemplateManager::instance())
{}
void ConfigurationManager::setupConnections()
@@ -81,35 +51,12 @@ void ConfigurationManager::setupConnections()
connect(&m_generalSettings.ccSelectProvider, &Button::clicked, this, &Config::selectProvider);
connect(&m_generalSettings.caSelectProvider, &Button::clicked, this, &Config::selectProvider);
connect(&m_generalSettings.qrSelectProvider, &Button::clicked, this, &Config::selectProvider);
connect(&m_generalSettings.ccSelectModel, &Button::clicked, this, &Config::selectModel);
connect(&m_generalSettings.caSelectModel, &Button::clicked, this, &Config::selectModel);
connect(&m_generalSettings.qrSelectModel, &Button::clicked, this, &Config::selectModel);
connect(&m_generalSettings.ccSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
connect(&m_generalSettings.caSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
connect(&m_generalSettings.qrSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
connect(&m_generalSettings.ccSetUrl, &Button::clicked, this, &Config::selectUrl);
connect(&m_generalSettings.caSetUrl, &Button::clicked, this, &Config::selectUrl);
connect(&m_generalSettings.qrSetUrl, &Button::clicked, this, &Config::selectUrl);
connect(
&m_generalSettings.ccPreset1SelectProvider, &Button::clicked, this, &Config::selectProvider);
connect(&m_generalSettings.ccPreset1SetUrl, &Button::clicked, this, &Config::selectUrl);
connect(&m_generalSettings.ccPreset1SelectModel, &Button::clicked, this, &Config::selectModel);
connect(
&m_generalSettings.ccPreset1SelectTemplate, &Button::clicked, this, &Config::selectTemplate);
connect(&m_generalSettings.ccTemplate, &Utils::StringAspect::changed, this, [this]() {
updateTemplateDescription(m_generalSettings.ccTemplate);
});
connect(&m_generalSettings.caTemplate, &Utils::StringAspect::changed, this, [this]() {
updateTemplateDescription(m_generalSettings.caTemplate);
});
connect(&m_generalSettings.qrTemplate, &Utils::StringAspect::changed, this, [this]() {
updateTemplateDescription(m_generalSettings.qrTemplate);
});
}
void ConfigurationManager::selectProvider()
@@ -122,15 +69,13 @@ void ConfigurationManager::selectProvider()
auto &targetSettings = (settingsButton == &m_generalSettings.ccSelectProvider)
? m_generalSettings.ccProvider
: settingsButton == &m_generalSettings.ccPreset1SelectProvider
? m_generalSettings.ccPreset1Provider
: settingsButton == &m_generalSettings.qrSelectProvider
? m_generalSettings.qrProvider
: m_generalSettings.caProvider;
QTimer::singleShot(0, this, [this, providersList, &targetSettings] {
m_generalSettings.showSelectionDialog(
providersList, targetSettings, Tr::tr("Select LLM Provider"), Tr::tr("Providers:"));
m_generalSettings.showSelectionDialog(providersList,
targetSettings,
Tr::tr("Select LLM Provider"),
Tr::tr("Providers:"));
});
}
@@ -141,39 +86,32 @@ void ConfigurationManager::selectModel()
return;
const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectModel);
const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectModel);
const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectModel);
const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue()
: isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue()
: isQuickRefactor ? m_generalSettings.qrProvider.volatileValue()
: m_generalSettings.caProvider.volatileValue();
: m_generalSettings.caProvider.volatileValue();
const auto providerUrl = isCodeCompletion ? m_generalSettings.ccUrl.volatileValue()
: isPreset1 ? m_generalSettings.ccPreset1Url.volatileValue()
: isQuickRefactor ? m_generalSettings.qrUrl.volatileValue()
: m_generalSettings.caUrl.volatileValue();
auto *targetSettings = &(isCodeCompletion ? m_generalSettings.ccModel
: isPreset1 ? m_generalSettings.ccPreset1Model
: isQuickRefactor ? m_generalSettings.qrModel
: m_generalSettings.caModel);
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccModel : m_generalSettings.caModel;
if (auto provider = m_providersManager.getProviderByName(providerName)) {
if (!provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::ModelListing)) {
m_generalSettings.showModelsNotSupportedDialog(*targetSettings);
if (!provider->supportsModelListing()) {
m_generalSettings.showModelsNotSupportedDialog(targetSettings);
return;
}
provider->getInstalledModels(providerUrl)
.then(this, [this, targetSettings](const QList<QString> &modelList) {
if (modelList.isEmpty()) {
m_generalSettings.showModelsNotFoundDialog(*targetSettings);
return;
}
m_generalSettings.showSelectionDialog(
modelList, *targetSettings, Tr::tr("Select LLM Model"), Tr::tr("Models:"));
});
const auto modelList = provider->getInstalledModels(providerUrl);
if (modelList.isEmpty()) {
m_generalSettings.showModelsNotFoundDialog(targetSettings);
return;
}
QTimer::singleShot(0, &m_generalSettings, [this, modelList, &targetSettings]() {
m_generalSettings.showSelectionDialog(
modelList, targetSettings, Tr::tr("Select LLM Model"), Tr::tr("Models:"));
});
}
}
@@ -184,26 +122,18 @@ void ConfigurationManager::selectTemplate()
return;
const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectTemplate);
const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectTemplate);
const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectTemplate);
const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue()
: isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue()
: isQuickRefactor ? m_generalSettings.qrProvider.volatileValue()
: m_generalSettings.caProvider.volatileValue();
auto providerID = m_providersManager.getProviderByName(providerName)->providerID();
const auto templateList = isCodeCompletion || isPreset1
? m_templateManger.getFimTemplatesForProvider(providerID)
: m_templateManger.getChatTemplatesForProvider(providerID);
const auto templateList = isCodeCompletion ? m_templateManger.fimTemplatesNames()
: m_templateManger.chatTemplatesNames();
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccTemplate
: isPreset1 ? m_generalSettings.ccPreset1Template
: isQuickRefactor ? m_generalSettings.qrTemplate
: m_generalSettings.caTemplate;
QTimer::singleShot(0, &m_generalSettings, [this, templateList, &targetSettings]() {
m_generalSettings.showSelectionDialog(
templateList, targetSettings, Tr::tr("Select Template"), Tr::tr("Templates:"));
m_generalSettings.showSelectionDialog(templateList,
targetSettings,
Tr::tr("Select Template"),
Tr::tr("Templates:"));
});
}
@@ -220,11 +150,8 @@ void ConfigurationManager::selectUrl()
urls.append(url);
}
auto &targetSettings = (settingsButton == &m_generalSettings.ccSetUrl) ? m_generalSettings.ccUrl
: settingsButton == &m_generalSettings.ccPreset1SetUrl
? m_generalSettings.ccPreset1Url
: settingsButton == &m_generalSettings.qrSetUrl
? m_generalSettings.qrUrl
auto &targetSettings = (settingsButton == &m_generalSettings.ccSetUrl)
? m_generalSettings.ccUrl
: m_generalSettings.caUrl;
QTimer::singleShot(0, &m_generalSettings, [this, urls, &targetSettings]() {

View File

@@ -1,12 +1,28 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* Copyright (C) 2024 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 "pluginllmcore/PromptTemplateManager.hpp"
#include "pluginllmcore/ProvidersManager.hpp"
#include "llmcore/PromptTemplateManager.hpp"
#include "llmcore/ProvidersManager.hpp"
#include "settings/GeneralSettings.hpp"
namespace QodeAssist {
@@ -20,11 +36,6 @@ public:
void init();
void updateTemplateDescription(const Utils::StringAspect &templateAspect);
void updateAllTemplateDescriptions();
void checkTemplate(const Utils::StringAspect &templateAspect);
void checkAllTemplate();
public slots:
void selectProvider();
void selectModel();
@@ -38,8 +49,8 @@ private:
ConfigurationManager &operator=(const ConfigurationManager &) = delete;
Settings::GeneralSettings &m_generalSettings;
PluginLLMCore::ProvidersManager &m_providersManager;
PluginLLMCore::PromptTemplateManager &m_templateManger;
LLMCore::ProvidersManager &m_providersManager;
LLMCore::PromptTemplateManager &m_templateManger;
void setupConnections();
};

249
DocumentContextReader.cpp Normal file
View File

@@ -0,0 +1,249 @@
/*
* Copyright (C) 2024 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 "DocumentContextReader.hpp"
#include <QFileInfo>
#include <QTextBlock>
#include <languageserverprotocol/lsptypes.h>
#include "core/ChangesManager.h"
#include "settings/CodeCompletionSettings.hpp"
const QRegularExpression &getYearRegex()
{
static const QRegularExpression yearRegex("\\b(19|20)\\d{2}\\b");
return yearRegex;
}
const QRegularExpression &getNameRegex()
{
static const QRegularExpression nameRegex("\\b[A-Z][a-z.]+ [A-Z][a-z.]+\\b");
return nameRegex;
}
const QRegularExpression &getCommentRegex()
{
static const QRegularExpression
commentRegex(R"((/\*[\s\S]*?\*/|//.*$|#.*$|//{2,}[\s\S]*?//{2,}))",
QRegularExpression::MultilineOption);
return commentRegex;
}
namespace QodeAssist {
DocumentContextReader::DocumentContextReader(TextEditor::TextDocument *textDocument)
: m_textDocument(textDocument)
, m_document(textDocument->document())
{
m_copyrightInfo = findCopyright();
}
QString DocumentContextReader::getLineText(int lineNumber, int cursorPosition) const
{
if (!m_document || lineNumber < 0)
return QString();
QTextBlock block = m_document->begin();
int currentLine = 0;
while (block.isValid()) {
if (currentLine == lineNumber) {
QString text = block.text();
if (cursorPosition >= 0 && cursorPosition <= text.length()) {
text = text.left(cursorPosition);
}
return text;
}
block = block.next();
currentLine++;
}
return QString();
}
QString DocumentContextReader::getContextBefore(int lineNumber,
int cursorPosition,
int linesCount) const
{
int effectiveStartLine;
if (m_copyrightInfo.found) {
effectiveStartLine = qMax(m_copyrightInfo.endLine + 1, lineNumber - linesCount);
} else {
effectiveStartLine = qMax(0, lineNumber - linesCount);
}
return getContextBetween(effectiveStartLine, lineNumber, cursorPosition);
}
QString DocumentContextReader::getContextAfter(int lineNumber,
int cursorPosition,
int linesCount) const
{
int endLine = qMin(m_document->blockCount() - 1, lineNumber + linesCount);
return getContextBetween(lineNumber + 1, endLine, cursorPosition);
}
QString DocumentContextReader::readWholeFileBefore(int lineNumber, int cursorPosition) const
{
int startLine = 0;
if (m_copyrightInfo.found) {
startLine = m_copyrightInfo.endLine + 1;
}
startLine = qMin(startLine, lineNumber);
QString result = getContextBetween(startLine, lineNumber, cursorPosition);
return result;
}
QString DocumentContextReader::readWholeFileAfter(int lineNumber, int cursorPosition) const
{
return getContextBetween(lineNumber, m_document->blockCount() - 1, cursorPosition);
}
QString DocumentContextReader::getLanguageAndFileInfo() const
{
if (!m_textDocument)
return QString();
QString language = LanguageServerProtocol::TextDocumentItem::mimeTypeToLanguageId(
m_textDocument->mimeType());
QString mimeType = m_textDocument->mimeType();
QString filePath = m_textDocument->filePath().toString();
QString fileExtension = QFileInfo(filePath).suffix();
return QString("Language: %1 (MIME: %2) filepath: %3(%4)\n\n")
.arg(language, mimeType, filePath, fileExtension);
}
CopyrightInfo DocumentContextReader::findCopyright()
{
CopyrightInfo result = {-1, -1, false};
QString text = m_document->toPlainText();
QRegularExpressionMatchIterator matchIterator = getCommentRegex().globalMatch(text);
QList<CopyrightInfo> copyrightBlocks;
while (matchIterator.hasNext()) {
QRegularExpressionMatch match = matchIterator.next();
QString matchedText = match.captured().toLower();
if (matchedText.contains("copyright") || matchedText.contains("(C)")
|| matchedText.contains("(c)") || matchedText.contains("©")
|| getYearRegex().match(text).hasMatch() || getNameRegex().match(text).hasMatch()) {
int startPos = match.capturedStart();
int endPos = match.capturedEnd();
CopyrightInfo info;
info.startLine = m_document->findBlock(startPos).blockNumber();
info.endLine = m_document->findBlock(endPos).blockNumber();
info.found = true;
copyrightBlocks.append(info);
}
}
for (int i = 0; i < copyrightBlocks.size() - 1; ++i) {
if (copyrightBlocks[i].endLine + 1 >= copyrightBlocks[i + 1].startLine) {
copyrightBlocks[i].endLine = copyrightBlocks[i + 1].endLine;
copyrightBlocks.removeAt(i + 1);
--i;
}
}
if (!copyrightBlocks.isEmpty()) { // temproary solution, need cache
return copyrightBlocks.first();
}
return result;
}
QString DocumentContextReader::getContextBetween(int startLine,
int endLine,
int cursorPosition) const
{
QString context;
for (int i = startLine; i <= endLine; ++i) {
QTextBlock block = m_document->findBlockByNumber(i);
if (!block.isValid()) {
break;
}
if (i == endLine) {
context += block.text().left(cursorPosition);
} else {
context += block.text() + "\n";
}
}
return context;
}
CopyrightInfo DocumentContextReader::copyrightInfo() const
{
return m_copyrightInfo;
}
LLMCore::ContextData DocumentContextReader::prepareContext(int lineNumber, int cursorPosition) const
{
QString contextBefore = getContextBefore(lineNumber, cursorPosition);
QString contextAfter = getContextAfter(lineNumber, cursorPosition);
QString fileContext;
if (Settings::codeCompletionSettings().useFilePathInContext())
fileContext.append("\n ").append(getLanguageAndFileInfo());
if (Settings::codeCompletionSettings().useProjectChangesCache())
fileContext.append("\n ").append(
ChangesManager::instance().getRecentChangesContext(m_textDocument));
return {contextBefore, contextAfter, fileContext};
}
QString DocumentContextReader::getContextBefore(int lineNumber, int cursorPosition) const
{
if (Settings::codeCompletionSettings().readFullFile()) {
return readWholeFileBefore(lineNumber, cursorPosition);
} else {
int effectiveStartLine;
int beforeCursor = Settings::codeCompletionSettings().readStringsBeforeCursor();
if (m_copyrightInfo.found) {
effectiveStartLine = qMax(m_copyrightInfo.endLine + 1, lineNumber - beforeCursor);
} else {
effectiveStartLine = qMax(0, lineNumber - beforeCursor);
}
return getContextBetween(effectiveStartLine, lineNumber, cursorPosition);
}
}
QString DocumentContextReader::getContextAfter(int lineNumber, int cursorPosition) const
{
if (Settings::codeCompletionSettings().readFullFile()) {
return readWholeFileAfter(lineNumber, cursorPosition);
} else {
int endLine = qMin(m_document->blockCount() - 1,
lineNumber + Settings::codeCompletionSettings().readStringsAfterCursor());
return getContextBetween(lineNumber + 1, endLine, -1);
}
}
} // namespace QodeAssist

64
DocumentContextReader.hpp Normal file
View File

@@ -0,0 +1,64 @@
/*
* Copyright (C) 2024 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 <QTextDocument>
#include <texteditor/textdocument.h>
#include <llmcore/ContextData.hpp>
namespace QodeAssist {
struct CopyrightInfo
{
int startLine;
int endLine;
bool found;
};
class DocumentContextReader
{
public:
DocumentContextReader(TextEditor::TextDocument *textDocument);
QString getLineText(int lineNumber, int cursorPosition = -1) const;
QString getContextBefore(int lineNumber, int cursorPosition, int linesCount) const;
QString getContextAfter(int lineNumber, int cursorPosition, int linesCount) const;
QString readWholeFileBefore(int lineNumber, int cursorPosition) const;
QString readWholeFileAfter(int lineNumber, int cursorPosition) const;
QString getLanguageAndFileInfo() const;
CopyrightInfo findCopyright();
QString getContextBetween(int startLine, int endLine, int cursorPosition) const;
CopyrightInfo copyrightInfo() const;
LLMCore::ContextData prepareContext(int lineNumber, int cursorPosition) const;
private:
QString getContextBefore(int lineNumber, int cursorPosition) const;
QString getContextAfter(int lineNumber, int cursorPosition) const;
private:
TextEditor::TextDocument *m_textDocument;
QTextDocument *m_document;
CopyrightInfo m_copyrightInfo;
};
} // namespace QodeAssist

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