feat: add support acp in common chat (#369)

This commit is contained in:
Petr Mironychev
2026-07-21 13:10:00 +02:00
committed by GitHub
parent af85efb554
commit 442263a6a7
158 changed files with 14270 additions and 4118 deletions
+7
View File
@@ -64,6 +64,7 @@ add_qtc_plugin(QodeAssist
LLMQore
Skills
QodeAssistSession
QodeAssistAcp
QodeAssistChatViewplugin
SOURCES
.github/workflows/build_cmake.yml
@@ -167,10 +168,14 @@ add_qtc_plugin(QodeAssist
sources/tools/TodoTool.hpp sources/tools/TodoTool.cpp
sources/tools/ReadOriginalHistoryTool.hpp sources/tools/ReadOriginalHistoryTool.cpp
sources/tools/SkillTool.hpp sources/tools/SkillTool.cpp
sources/tools/EditorStateTools.hpp sources/tools/EditorStateTools.cpp
sources/mcp/AgentKnowledgeServer.hpp sources/mcp/AgentKnowledgeServer.cpp
sources/mcp/McpServerManager.hpp sources/mcp/McpServerManager.cpp
sources/mcp/McpServerConnection.hpp sources/mcp/McpServerConnection.cpp
sources/mcp/McpClientsManager.hpp sources/mcp/McpClientsManager.cpp
sources/settings/McpClientsListAspect.hpp sources/settings/McpClientsListAspect.cpp
sources/settings/AgentsSettings.hpp sources/settings/AgentsSettings.cpp
sources/settings/AgentsWidget.hpp sources/settings/AgentsWidget.cpp
)
target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sources)
@@ -180,6 +185,8 @@ extend_qtc_plugin(QodeAssist
DEPENDS Qt::Test
SOURCES
tests/QodeAssistTest.hpp tests/QodeAssistTest.cpp
tests/FakeAcpAgent.hpp
tests/FakeLlmProvider.hpp
)
if(WITH_TESTS)
+1
View File
@@ -10,5 +10,6 @@
<file>resources/images/[email protected]</file>
<file>resources/images/qode-assist-chat-icon.png</file>
<file>resources/images/[email protected]</file>
<file>resources/agents/acp-registry-snapshot.json</file>
</qresource>
</RCC>
+20 -38
View File
@@ -38,10 +38,10 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
- **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on
- **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge)
- **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet)
- **File Context** — attach, link, or auto-sync open editor files for richer prompts
- **File Context** — attach files and images to your messages for richer prompts
- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint
- **Reasoning / Thinking** — streamed chain-of-thought is shown for reasoning models across Claude, Google, OpenAI Responses, and any OpenAI-compatible endpoint that returns `reasoning_content` (DeepSeek, Qwen QwQ/Qwen3-Thinking, LM Studio, OpenRouter, …)
- **Customizable** — per-project rules (`.qodeassist/rules/`), agent roles, reusable refactor templates, full prompt-template control
- **Customizable** — per-project and global agent skills, reusable refactor templates, full prompt-template control
**Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users!
@@ -217,9 +217,7 @@ For optimal coding assistance, we recommend using these top-tier models:
### Additional Configuration
- **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts
- **[Chat Summarization](docs/chat-summarization.md)** - Compress conversations to save context tokens
- **[Project Rules](docs/project-rules.md)** - Customize AI behavior for your project
- **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore`
## Features
@@ -255,10 +253,8 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General
- Multiple chat panels: side panel, bottom panel, and popup window
- Chat history with auto-save and restore
- Token usage monitoring
- **[Agent Roles](docs/agent-roles.md)** - Switch between AI personas (Developer, Reviewer, custom roles)
- **[Chat Summarization](docs/chat-summarization.md)** - Compress long conversations into AI-generated summaries
- **[File Context](docs/file-context.md)** - Attach or link files for better context
- Automatic syncing with open editor files (optional)
- **[File Context](docs/file-context.md)** - Attach files for better context
- Extended thinking / reasoning mode - shows streamed chain-of-thought for reasoning models (Claude, Google, OpenAI Responses, and OpenAI-compatible endpoints returning `reasoning_content` such as DeepSeek, Qwen, LM Studio, OpenRouter)
### Quick Refactoring
@@ -350,15 +346,13 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ Examples: Codestral, Qwen2.5-Coder, DeepSeek-Coder │
│ │
│ 1. System Prompt (from Code Completion Settings - FIM variant) │
│ 2. Project Rules:
│ └─ .qodeassist/rules/completion/*.md │
│ 3. Open Files Context (optional, if enabled): │
│ 2. Open Files Context (optional, if enabled):
│ └─ Currently open editor files │
4. Code Context: │
3. Code Context: │
│ ├─ Code before cursor (prefix) │
│ └─ Code after cursor (suffix) │
│ │
│ Final Prompt: FIM_Template(Prefix: SystemPrompt + Rules + OpenFiles + │
│ Final Prompt: FIM_Template(Prefix: SystemPrompt + OpenFiles +
│ CodeBefore, │
│ Suffix: CodeAfter) │
└─────────────────────────────────────────────────────────────────────────────┘
@@ -377,18 +371,16 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ │
│ 1. System Prompt (from Code Completion Settings - Non-FIM variant) │
│ └─ Includes response formatting instructions │
│ 2. Project Rules:
│ └─ .qodeassist/rules/completion/*.md │
│ 3. Open Files Context (optional, if enabled): │
│ 2. Open Files Context (optional, if enabled):
│ └─ Currently open editor files │
4. Code Context: │
3. Code Context: │
│ ├─ File information (language, path) │
│ ├─ Code before cursor │
│ ├─ <cursor> marker │
│ └─ Code after cursor │
5. User Message: "Complete the code at cursor position" │
4. User Message: "Complete the code at cursor position" │
│ │
│ Final Prompt: [System: SystemPrompt + Rules]
│ Final Prompt: [System: SystemPrompt]
│ [User: OpenFiles + Context + CompletionRequest] │
└─────────────────────────────────────────────────────────────────────────────┘
```
@@ -403,15 +395,10 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ CHAT ASSISTANT │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. System Prompt (from Chat Assistant Settings) │
│ 2. Agent Role (optional, from role selector):
└─ Role-specific system prompt (Developer, Reviewer, custom)
│ 3. Project Rules: │
│ ├─ .qodeassist/rules/common/*.md │
│ └─ .qodeassist/rules/chat/*.md │
│ 2. Project Info (active project name, source root, build directory)
3. Skills (always-on bodies + catalog + /invoked skills)
│ 4. File Context (optional): │
─ Attached files (manual) │
│ ├─ Linked files (persistent) │
│ └─ Open editor files (if auto-sync enabled) │
─ Attached files (manual) │
│ 5. Tool Definitions (if enabled): │
│ ├─ ReadProjectFileByName │
│ ├─ ListProjectFiles │
@@ -420,7 +407,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ 6. Conversation History │
│ 7. User Message │
│ │
│ Final Prompt: [System: SystemPrompt + AgentRole + Rules + Tools]
│ Final Prompt: [System: SystemPrompt + ProjectInfo + Skills + Tools] │
│ [History: Previous messages] │
│ [User: FileContext + UserMessage] │
└─────────────────────────────────────────────────────────────────────────────┘
@@ -436,10 +423,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ QUICK REFACTORING │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. System Prompt (from Quick Refactor Settings) │
│ 2. Project Rules:
│ ├─ .qodeassist/rules/common/*.md │
│ └─ .qodeassist/rules/quickrefactor/*.md │
│ 3. Code Context: │
│ 2. Code Context:
│ ├─ File information (language, path) │
│ ├─ Code before selection (configurable amount) │
│ ├─ <selection_start> marker │
@@ -447,15 +431,15 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ ├─ <selection_end> marker │
│ ├─ <cursor> marker (position within selection) │
│ └─ Code after selection (configurable amount) │
4. Refactor Instruction: │
3. Refactor Instruction: │
│ ├─ Built-in (e.g., "Improve Code", "Alternative Solution") │
│ ├─ Custom Instruction (from library) │
│ │ └─ ~/.config/QtProject/qtcreator/qodeassist/ │
│ │ quick_refactor/instructions/*.json │
│ └─ Additional Details (optional user input) │
5. Tool Definitions (if enabled) │
4. Tool Definitions (if enabled) │
│ │
│ Final Prompt: [System: SystemPrompt + Rules]
│ Final Prompt: [System: SystemPrompt]
│ [User: Context + Markers + Instruction + Details] │
└─────────────────────────────────────────────────────────────────────────────┘
```
@@ -464,9 +448,8 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
### Key Points
- **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure
- **System Prompts** are configured independently for each feature in Settings
- **Agent Roles** add role-specific prompts on top of the base system prompt (Chat only)
- **Skills** provide reusable, project- or globally-scoped instructions for Chat: always-on skills are injected into the system prompt, others are invoked with `/skill-name`
- **FIM vs Non-FIM models** for code completion use different System Prompts:
- FIM models: Direct completion prompt
- Non-FIM models: Prompt includes response formatting instructions
@@ -474,7 +457,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
- **Custom Instructions** provide reusable templates that can be augmented with specific details
- **Tool Calling** is available for Chat and Quick Refactor when enabled
See [Project Rules Documentation](docs/project-rules.md), [Agent Roles Guide](docs/agent-roles.md), and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
See the [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
## QtCreator Version Compatibility
@@ -522,7 +505,6 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
- [x] Diff sharing with models
- [x] Tools / function calling (file I/O, build, terminal, diagnostics)
- [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool)
- [x] Project-specific rules (`.qodeassist/rules/`)
- [x] MCP (Model Context Protocol) — QodeAssist as a server
- [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported)
- [ ] Full project source sharing
+114
View File
@@ -0,0 +1,114 @@
# ACP agents
QodeAssist can talk to external coding agents that speak the
[Agent Client Protocol](https://agentclientprotocol.com) (ACP). The **QodeAssist >
Agents** settings page lists the agents it knows about and lets you verify that one
starts and answers the ACP handshake.
## Where the list comes from
The list is merged from three sources. When the same agent `id` appears in more than
one, the higher entry wins:
1. **Your JSON files** in `qodeassist/agents/` inside the Qt Creator user resource
directory (**Open Agents Folder...** opens it). Press **Reload** after editing.
2. **The ACP registry**, downloaded from
`https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json` when you press
**Refresh from Registry** and cached on disk. There is no background polling: the
list only changes when you ask for it.
3. **A bundled snapshot**, so the list is never empty on a fresh install or offline.
If a download fails, the cached copy and the bundled snapshot stay in place.
## Finding the agent's executable
Qt Creator started from the Dock or a launcher does not inherit your shell's `PATH`, so
`npx` or `uvx` installed through Homebrew, nvm or uv is often invisible to it — the agent
then fails to start with `execve: No such file or directory`. **Extra PATH for launching
agents** on the Agents page lists directories that are both searched for the executable
and prepended to the agent's own `PATH`. On macOS it defaults to
`/opt/homebrew/bin:/usr/local/bin`. An `env` entry in the agent definition still wins over
it.
## Credentials and other environment variables
Some agents authenticate through an environment variable rather than the ACP handshake —
the Claude adapter, for instance, declares no `authMethods` at all and reads
`CLAUDE_CODE_OAUTH_TOKEN`. Since a Qt Creator started from the dock inherits no shell
environment, that variable never reaches the agent and it fails with an expired-session
error.
**Forward these variables to agents** on the Agents page takes variable *names*, not
values, and defaults to `CLAUDE_CODE_OAUTH_TOKEN`. For each name QodeAssist uses the value
from its own environment when it has one — which covers Windows, and any platform when Qt
Creator was launched from a terminal. On macOS and Linux the remaining names are read once
per session from a login shell (`$SHELL -l -i -c env`), so a token exported in your shell
profile works without being copied anywhere. Nothing is stored in the settings but the
names.
An `env` entry in the agent definition still wins over a forwarded variable.
## Distributions
An entry describes how the agent is started:
- `npx` — launched as `npx -y <package> <args>`. Requires Node.js on `PATH`.
- `uvx` — launched as `uvx <package> <args>`. Requires uv on `PATH`.
- `command` — launched directly. This is the QodeAssist extension used by your own
JSON files.
- `binary` — a downloadable platform archive. QodeAssist does not download binaries,
so these agents are listed but cannot be started. Install the agent yourself and add
a `command` entry for it.
## Defining your own agent
Create a `.json` file in the agents folder. A file holds either a single agent object
or a registry-shaped `{"agents": [...]}` document.
```json
{
"id": "my-agent",
"name": "My Agent",
"version": "1.0.0",
"description": "Locally installed agent",
"distribution": {
"command": {
"cmd": "/usr/local/bin/my-agent",
"args": ["acp"],
"env": { "MY_AGENT_LOG": "debug" }
}
}
}
```
Reusing an `id` from the registry overrides that entry — which is how you make a
`binary` agent launchable after installing it manually:
```json
{
"id": "cursor",
"name": "Cursor",
"distribution": {
"command": { "cmd": "/usr/local/bin/cursor-agent", "args": ["acp"] }
}
}
```
Agent definitions hold no secrets. Agents that need credentials authenticate through
the ACP handshake.
## Testing an agent
Select an agent and press **Test**. QodeAssist starts the process, runs the ACP
`initialize` handshake and reports the agent's name, version, protocol version,
whether it supports session persistence (`loadSession`), its prompt and MCP
capabilities, and its authentication methods. On failure it shows the error together
with the agent's own output.
## Long conversations and reopened sessions
When an agent conversation grows long, the **Hand over** button in the chat bottom
bar summarizes the transcript with your Chat Assistant configuration and continues in
a fresh agent session seeded with that summary — see
[chat-summarization.md](chat-summarization.md) for details. The same handover is
offered on the read-only banner when a saved agent session can no longer be resumed.
-174
View File
@@ -1,174 +0,0 @@
# Agent Roles
Agent Roles allow you to define different AI personas with specialized system prompts for various tasks. Switch between roles instantly in the chat interface to adapt the AI's behavior to your current needs.
## Overview
Agent Roles are reusable system prompt configurations that modify how the AI assistant responds. Instead of manually changing system prompts, you can create roles like "Developer", "Code Reviewer", or "Documentation Writer" and switch between them with a single click.
**Key Features:**
- **Quick Switching**: Change roles from the chat toolbar dropdown
- **Custom Prompts**: Each role has its own specialized system prompt
- **Built-in Roles**: Pre-configured Developer and Code Reviewer roles
- **Persistent**: Roles are saved locally and loaded on startup
- **Extensible**: Create unlimited custom roles for different tasks
## Default Roles
QodeAssist comes with three built-in roles:
### Developer
Experienced Qt/C++ developer with a structured workflow: analyze the problem, propose a solution, wait for approval, then implement. Best for implementation tasks where you want thoughtful, minimal code changes.
### Code Reviewer
Expert C++/QML code reviewer specializing in C++20 and Qt6. Checks for bugs, memory leaks, thread safety, Qt patterns, and production readiness. Provides direct, specific feedback with code examples.
### Researcher
Research-oriented developer who investigates problems and explores solutions. Analyzes problems, presents multiple approaches with trade-offs, and recommends the best option. Does not write implementation code — focuses on helping you make informed decisions.
## Using Agent Roles
### Switching Roles in Chat
1. Open the Chat Assistant (side panel, bottom panel, or popup window)
2. Locate the **Role selector** dropdown in the top toolbar (next to the configuration selector)
3. Select a role from the dropdown
4. The AI will now use the selected role's system prompt
**Note**: Selecting "No Role" uses only the base system prompt without role specialization.
### Viewing Active Role
Click the **Context** button (📋) in the chat toolbar to view:
- Base system prompt
- Current agent role and its system prompt
- Active project rules
## Managing Agent Roles
### Opening the Role Manager
Navigate to: `Qt Creator → Preferences → QodeAssist → Chat Assistant`
Scroll down to the **Agent Roles** section where you can manage all your roles.
### Creating a New Role
1. Click **Add...** button
2. Fill in the role details:
- **Name**: Display name shown in the dropdown (e.g., "Documentation Writer")
- **ID**: Unique identifier for the role file (e.g., "doc_writer")
- **Description**: Brief explanation of the role's purpose
- **System Prompt**: The specialized instructions for this role
3. Click **OK** to save
### Editing a Role
1. Select a role from the list
2. Click **Edit...** or double-click the role
3. Modify the fields as needed
4. Click **OK** to save changes
**Note**: Built-in roles cannot be edited directly. Duplicate them to create a modifiable copy.
### Duplicating a Role
1. Select a role to duplicate
2. Click **Duplicate...**
3. Modify the copy as needed
4. Click **OK** to save as a new role
### Deleting a Role
1. Select a custom role (built-in roles cannot be deleted)
2. Click **Delete**
3. Confirm deletion
## Creating Effective Roles
### System Prompt Tips
- **Be specific**: Clearly define the role's expertise and focus areas
- **Set expectations**: Describe the desired response format and style
- **Include guidelines**: Add specific rules or constraints for responses
- **Use structured prompts**: Break down complex roles into bullet points
## Storage Location
Agent roles are stored as JSON files in:
```
~/.config/QtProject/qtcreator/qodeassist/agent_roles/
```
**On different platforms:**
- **Linux**: `~/.config/QtProject/qtcreator/qodeassist/agent_roles/`
- **macOS**: `~/Library/Application Support/QtProject/Qt Creator/qodeassist/agent_roles/`
- **Windows**: `%APPDATA%\QtProject\qtcreator\qodeassist\agent_roles\`
### File Format
Each role is stored as a JSON file named `{id}.json`:
```json
{
"id": "doc_writer",
"name": "Documentation Writer",
"description": "Technical documentation and code comments",
"systemPrompt": "You are a technical documentation specialist...",
"isBuiltin": false
}
```
### Manual Editing
You can:
- Edit JSON files directly in any text editor
- Copy role files between machines
- Share roles with team members
- Version control your roles
- Click **Open Roles Folder...** to quickly access the directory
## How Roles Work
When a role is selected, the final system prompt is composed as:
```
┌─────────────────────────────────────────────────┐
│ Final System Prompt = Base Prompt + Role Prompt │
├─────────────────────────────────────────────────┤
│ 1. Base System Prompt (from Chat Settings) │
│ 2. Agent Role System Prompt │
│ 3. Project Rules (common/ + chat/) │
│ 4. Linked Files Context │
└─────────────────────────────────────────────────┘
```
This allows roles to augment rather than replace your base configuration.
## Best Practices
1. **Keep roles focused**: Each role should have a clear, specific purpose
2. **Use descriptive names**: Make it easy to identify roles at a glance
3. **Test your prompts**: Verify roles produce the expected behavior
4. **Iterate and improve**: Refine prompts based on AI responses
5. **Share with team**: Export and share useful roles with colleagues
## Troubleshooting
### Role Not Appearing in Dropdown
- Restart Qt Creator after adding roles manually
- Check JSON file format validity
- Verify file is in the correct directory
### Role Behavior Not as Expected
- Review the system prompt for clarity
- Check if base system prompt conflicts with role prompt
- Try a more specific or detailed prompt
## Related Documentation
- [Project Rules](project-rules.md) - Project-specific AI behavior customization
- [Chat Assistant Features](../README.md#chat-assistant) - Overview of chat functionality
- [File Context](file-context.md) - Attaching files to chat context
+21 -7
View File
@@ -11,7 +11,7 @@ When conversations grow long, they consume more context tokens with each message
- Important context for continuing the conversation
**Key Features:**
- **One-click compression**: Summarize directly from the chat toolbar
- **One-click compression**: Summarize directly from the chat bottom bar
- **Preserves original**: Creates a new chat file, keeping the original intact
- **Smart summaries**: AI extracts the most relevant information
- **Markdown formatted**: Summaries are well-structured and readable
@@ -21,10 +21,23 @@ When conversations grow long, they consume more context tokens with each message
### Compressing a Chat
1. Open any chat with conversation history
2. Click the **Compress** button (📦) in the chat top bar
2. Click the **Compress** button (📦) in the chat bottom bar
3. Wait for the AI to generate the summary
4. A new chat opens with the compressed summary
### Agent Sessions: Hand Over
In a chat bound to an ACP agent the same button reads **Hand over**. Instead of
writing a compressed chat file, it summarizes the transcript using your configured
Chat Assistant provider (the same LLM that powers compression) and starts a fresh
agent session with the summary as its opening context. The transcript in the chat
stays intact; only the agent-side session is replaced.
The button is disabled with an explanatory tooltip when the Chat Assistant
configuration is incomplete (provider, model, template, or URL missing), because the
summary is generated by that configuration. Auto-compression never triggers for agent
sessions — the agent manages its own context window.
### What Gets Preserved
The summarization process:
@@ -94,9 +107,12 @@ No additional configuration is required.
## Troubleshooting
### Compression Button Not Visible
- Ensure you have an active chat with messages
- Check that the chat top bar is visible
### Compression Button Disabled
- Hover the button: the tooltip explains why (for example, the Chat Assistant
configuration is unassigned, or an agent chat has nothing to summarize yet)
- Assign the chat feature in Preferences → QodeAssist if the tooltip says so
- While a compression or summary is running, the button is replaced by a progress
indicator with a Cancel action in the bottom bar
### Compression Fails
- Verify your Chat Assistant provider is configured correctly
@@ -110,6 +126,4 @@ No additional configuration is required.
## Related Documentation
- [Agent Roles](agent-roles.md) - Switch between AI personas
- [File Context](file-context.md) - Attach files to chat
- [Project Rules](project-rules.md) - Customize AI behavior
+5 -17
View File
@@ -1,6 +1,6 @@
# File Context Feature
QodeAssist provides two powerful ways to include source code files in your chat conversations: Attachments and Linked Files. Each serves a distinct purpose and helps provide better context for the AI assistant.
QodeAssist lets you include source code files in your chat conversations as attachments.
## Attached Files
@@ -14,20 +14,8 @@ Attachments are designed for one-time code analysis and specific queries:
- Quick implementation questions
- Files can be attached using the paperclip icon in the chat interface
- Multiple files can be attached to a single message
- Files can also be added by dragging them onto the chat panel or with `@` mentions
## Linked Files
Linked files provide persistent context throughout the conversation:
- Files remain accessible for the entire chat session
- Content is included in every message exchange
- Files are automatically refreshed - always using latest content from disk
- Perfect for:
- Long-term refactoring discussions
- Complex architectural changes
- Multi-file implementations
- Maintaining context across related questions
- Can be managed using the link icon in the chat interface
- Supports automatic syncing with open editor files (can be enabled in settings)
- Files can be added/removed at any time during the conversation
For persistent, conversation-long context, enable tools (or use an ACP agent): the
model reads the files it needs from the project itself, always seeing the latest
content from disk.
-35
View File
@@ -1,35 +0,0 @@
# Project Rules Configuration
QodeAssist supports project-specific rules to customize AI behavior for your codebase. Create a `.qodeassist/rules/` directory in your project root.
## Quick Start
```bash
mkdir -p .qodeassist/rules/{common,completion,chat,quickrefactor}
```
## Directory Structure
```
.qodeassist/
└── rules/
├── common/ # Applied to all contexts
├── completion/ # Code completion only
├── chat/ # Chat assistant only
└── quickrefactor/ # Quick refactor only
```
All `.md` files in each directory are automatically loaded and added to the system prompt.
## Example
Create `.qodeassist/rules/common/general.md`:
```markdown
# Project Guidelines
- Use snake_case for private members
- Prefix interfaces with 'I'
- Always document public APIs
- Prefer Qt containers over STL
```
-2
View File
@@ -206,7 +206,6 @@ The LLM receives:
- **Cursor Position**: Marked with `<cursor>` tag
- **Selection Markers**: `<selection_start>` and `<selection_end>` tags
- **Your Instructions**: Built-in, custom, or typed
- **Project Rules**: If configured (see [Project Rules](project-rules.md))
### Context Configuration
@@ -270,7 +269,6 @@ Fully local setup for offline or secure environments.
## Related Documentation
- [Project Rules](project-rules.md) - Project-specific AI behavior customization
- [File Context](file-context.md) - Attaching files to chat context
- [Ignoring Files](ignoring-files.md) - Exclude files from AI context
- [Provider Configuration](../README.md#configuration) - Setting up LLM providers
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,5 +4,6 @@ add_subdirectory(logger)
add_subdirectory(settings)
add_subdirectory(context)
add_subdirectory(session)
add_subdirectory(acp)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
-124
View File
@@ -1,124 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "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
-39
View File
@@ -1,39 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QStringList>
namespace QodeAssist::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
@@ -2,13 +2,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatFileManager.hpp"
#include "AttachmentStaging.hpp"
#include "Logger.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include <QUuid>
#include <QDateTime>
#include <QRegularExpression>
@@ -17,14 +16,14 @@
namespace QodeAssist::Chat {
ChatFileManager::ChatFileManager(QObject *parent)
AttachmentStaging::AttachmentStaging(QObject *parent)
: QObject(parent)
, m_intermediateStorageDir(getIntermediateStorageDir())
{}
ChatFileManager::~ChatFileManager() = default;
AttachmentStaging::~AttachmentStaging() = default;
QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
QStringList AttachmentStaging::processDroppedFiles(const QStringList &filePaths)
{
QStringList processedPaths;
processedPaths.reserve(filePaths.size());
@@ -52,17 +51,17 @@ QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
return processedPaths;
}
void ChatFileManager::setChatFilePath(const QString &chatFilePath)
void AttachmentStaging::setChatFilePath(const QString &chatFilePath)
{
m_chatFilePath = chatFilePath;
}
QString ChatFileManager::chatFilePath() const
QString AttachmentStaging::chatFilePath() const
{
return m_chatFilePath;
}
void ChatFileManager::clearIntermediateStorage()
void AttachmentStaging::clearIntermediateStorage()
{
QDir dir(m_intermediateStorageDir);
if (!dir.exists()) {
@@ -82,13 +81,13 @@ void ChatFileManager::clearIntermediateStorage()
}
}
bool ChatFileManager::isFileAccessible(const QString &filePath)
bool AttachmentStaging::isFileAccessible(const QString &filePath)
{
QFileInfo fileInfo(filePath);
return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable();
}
void ChatFileManager::cleanupGlobalIntermediateStorage()
void AttachmentStaging::cleanupGlobalIntermediateStorage()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
@@ -113,13 +112,13 @@ void ChatFileManager::cleanupGlobalIntermediateStorage()
}
if (removedCount > 0 || failedCount > 0) {
LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2")
LOG_MESSAGE(QString("AttachmentStaging global cleanup: removed=%1, failed=%2")
.arg(removedCount)
.arg(failedCount));
}
}
QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
QString AttachmentStaging::copyToIntermediateStorage(const QString &filePath)
{
QFileInfo fileInfo(filePath);
if (!fileInfo.exists() || !fileInfo.isFile()) {
@@ -154,7 +153,7 @@ QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
return destinationPath;
}
QString ChatFileManager::getIntermediateStorageDir()
QString AttachmentStaging::getIntermediateStorageDir()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
@@ -168,7 +167,7 @@ QString ChatFileManager::getIntermediateStorageDir()
return intermediatePath;
}
QString ChatFileManager::generateIntermediateFileName(const QString &originalPath)
QString AttachmentStaging::generateIntermediateFileName(const QString &originalPath)
{
const QFileInfo fileInfo(originalPath);
const QString extension = fileInfo.suffix();
@@ -7,17 +7,16 @@
#include <QObject>
#include <QString>
#include <QStringList>
#include <QMap>
namespace QodeAssist::Chat {
class ChatFileManager : public QObject
class AttachmentStaging : public QObject
{
Q_OBJECT
public:
explicit ChatFileManager(QObject *parent = nullptr);
~ChatFileManager();
explicit AttachmentStaging(QObject *parent = nullptr);
~AttachmentStaging();
QStringList processDroppedFiles(const QStringList &filePaths);
void setChatFilePath(const QString &chatFilePath);
+9 -8
View File
@@ -16,7 +16,10 @@ qt_add_qml_module(QodeAssistChatView
qml/chatparts/TextBlock.qml
qml/chatparts/ThinkingBlock.qml
qml/chatparts/ToolBlock.qml
qml/chatparts/PermissionBlock.qml
qml/chatparts/PlanBlock.qml
qml/chatparts/ChatItem.qml
qml/chatparts/BlockPayload.js
qml/controls/AttachedFilesPlace.qml
qml/controls/BottomBar.qml
@@ -26,7 +29,7 @@ qt_add_qml_module(QodeAssistChatView
qml/controls/SkillCommandPopup.qml
qml/controls/Toast.qml
qml/controls/TopBar.qml
qml/controls/SplitDropZone.qml
qml/controls/DropZone.qml
qml/controls/MessageNavigator.qml
RESOURCES
@@ -34,8 +37,6 @@ qt_add_qml_module(QodeAssistChatView
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
@@ -67,22 +68,21 @@ qt_add_qml_module(QodeAssistChatView
ChatModel.hpp ChatModel.cpp
ChatRootView.hpp ChatRootView.cpp
ChatController.hpp ChatController.cpp
ConversationPorts.hpp
ConversationCoordinator.hpp ConversationCoordinator.cpp
LlmChatBackend.hpp LlmChatBackend.cpp
MessagePart.hpp
ChatUtils.h ChatUtils.cpp
ChatSerializer.hpp ChatSerializer.cpp
ChatHistoryBridge.hpp ChatHistoryBridge.cpp
ChatFileStore.hpp ChatFileStore.cpp
TurnContextAdapters.hpp TurnContextAdapters.cpp
ChatView.hpp ChatView.cpp
ChatData.hpp
FileItem.hpp FileItem.cpp
ChatFileManager.hpp ChatFileManager.cpp
AttachmentStaging.hpp AttachmentStaging.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
)
@@ -98,6 +98,7 @@ target_link_libraries(QodeAssistChatView
QodeAssistSettings
Context
QodeAssistSession
QodeAssistAcp
QodeAssistUIControlsplugin
QodeAssistLogger
LLMQore
+59 -4
View File
@@ -25,15 +25,48 @@ ChatCompressor::ChatCompressor(QObject *parent)
: QObject(parent)
{}
QString ChatCompressor::configurationIssue()
{
auto &settings = Settings::generalSettings();
if (settings.caProvider().isEmpty())
return tr("no provider is assigned to the chat feature");
if (settings.caModel().isEmpty())
return tr("no model is assigned to the chat feature");
if (settings.caTemplate().isEmpty())
return tr("no prompt template is assigned to the chat feature");
if (settings.caUrl().isEmpty())
return tr("the chat feature has no URL configured");
if (!Providers::ProvidersManager::instance().getProviderByName(settings.caProvider()))
return tr("the provider \"%1\" is not available").arg(settings.caProvider());
if (!Templates::PromptTemplateManager::instance().getChatTemplateByName(settings.caTemplate()))
return tr("the prompt template \"%1\" is not available").arg(settings.caTemplate());
return {};
}
void ChatCompressor::startSummary(const Session::ConversationHistory &history)
{
beginCompression(QString(), history, /*summaryOnly*/ true);
}
void ChatCompressor::startCompression(
const QString &chatFilePath, const Session::ConversationHistory &history)
{
beginCompression(chatFilePath, history, /*summaryOnly*/ false);
}
void ChatCompressor::beginCompression(
const QString &chatFilePath, const Session::ConversationHistory &history, bool summaryOnly)
{
if (m_isCompressing) {
emit compressionFailed(tr("Compression already in progress"));
return;
}
if (chatFilePath.isEmpty()) {
if (!summaryOnly && chatFilePath.isEmpty()) {
emit compressionFailed(tr("No chat file to compress"));
return;
}
@@ -62,11 +95,13 @@ void ChatCompressor::startCompression(
}
m_isCompressing = true;
m_summaryOnly = summaryOnly;
m_rows = rows;
m_originalChatPath = chatFilePath;
m_accumulatedSummary.clear();
emit compressionStarted();
emit compressingChanged();
connectProviderSignals();
@@ -122,6 +157,19 @@ void ChatCompressor::onFullResponseReceived(const QString &requestId, const QStr
LOG_MESSAGE(
QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length()));
if (m_summaryOnly) {
const QString summary = m_accumulatedSummary.trimmed();
cleanupState();
if (summary.isEmpty()) {
emit compressionFailed(tr("The summary came back empty"));
return;
}
emit summaryReady(summary);
return;
}
QString compressedPath = createCompressedChatPath(m_originalChatPath);
if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) {
handleCompressionError(tr("Failed to save compressed chat"));
@@ -181,12 +229,13 @@ void ChatCompressor::buildRequestPayload(
QVector<LLMCore::Message> messages;
for (const Session::MessageRow &row : std::as_const(m_rows)) {
if (row.kind == Session::RowKind::Tool || row.kind == Session::RowKind::FileEdit
|| row.kind == Session::RowKind::Thinking)
const Session::RowTreatment treatment
= Session::rowTreatmentFor(Session::RowAudience::Compression, row.kind);
if (treatment == Session::RowTreatment::Omit)
continue;
LLMCore::Message apiMessage;
apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant";
apiMessage.content = row.content;
messages.append(apiMessage);
}
@@ -282,12 +331,18 @@ void ChatCompressor::cleanupState()
{
disconnectAllSignals();
const bool wasCompressing = m_isCompressing;
m_isCompressing = false;
m_summaryOnly = false;
m_currentRequestId.clear();
m_originalChatPath.clear();
m_accumulatedSummary.clear();
m_rows.clear();
m_provider = nullptr;
if (wasCompressing)
emit compressingChanged();
}
} // namespace QodeAssist::Chat
+14
View File
@@ -30,13 +30,22 @@ public:
void startCompression(
const QString &chatFilePath, const Session::ConversationHistory &history);
void startSummary(const Session::ConversationHistory &history);
signals:
void compressingChanged();
public:
bool isCompressing() const;
void cancelCompression();
static QString configurationIssue();
signals:
void compressionStarted();
void compressionCompleted(const QString &compressedChatPath);
void summaryReady(const QString &summary);
void compressionFailed(const QString &error);
private slots:
@@ -54,8 +63,13 @@ private:
void cleanupState();
void handleCompressionError(const QString &error);
void buildRequestPayload(QJsonObject &payload, Templates::PromptTemplate *promptTemplate);
void beginCompression(
const QString &chatFilePath,
const Session::ConversationHistory &history,
bool summaryOnly);
bool m_isCompressing = false;
bool m_summaryOnly = false;
QString m_currentRequestId;
QString m_originalChatPath;
QString m_accumulatedSummary;
@@ -8,9 +8,19 @@
#include "ConfigurationManager.hpp"
#include "GeneralSettings.hpp"
#include "acp/AgentCatalogStore.hpp"
namespace QodeAssist::Chat {
namespace {
QString agentEntry(const QString &agentName)
{
return ChatConfigurationController::tr("Agent: %1").arg(agentName);
}
} // namespace
ChatConfigurationController::ChatConfigurationController(QObject *parent)
: QObject(parent)
{
@@ -29,6 +39,26 @@ ChatConfigurationController::ChatConfigurationController(QObject *parent)
loadAvailableConfigurations();
}
void ChatConfigurationController::setAgentCatalog(Acp::AgentCatalogStore *store)
{
if (m_agents == store)
return;
if (m_agents)
disconnect(m_agents, nullptr, this, nullptr);
m_agents = store;
if (m_agents) {
connect(
m_agents,
&Acp::AgentCatalogStore::catalogChanged,
this,
&ChatConfigurationController::loadAvailableConfigurations);
}
loadAvailableConfigurations();
}
QStringList ChatConfigurationController::availableConfigurations() const
{
return m_availableConfigurations;
@@ -41,12 +71,33 @@ QString ChatConfigurationController::currentConfiguration() const
void ChatConfigurationController::updateCurrentConfiguration()
{
if (!m_boundAgentName.isEmpty()) {
m_currentConfiguration = agentEntry(m_boundAgentName);
emit currentConfigurationChanged();
return;
}
auto &settings = Settings::generalSettings();
m_currentConfiguration
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
emit currentConfigurationChanged();
}
void ChatConfigurationController::setBoundAgent(const Acp::AgentDefinition &agent)
{
m_boundAgentName = agent.name;
updateCurrentConfiguration();
}
void ChatConfigurationController::clearBoundAgent()
{
if (m_boundAgentName.isEmpty())
return;
m_boundAgentName.clear();
updateCurrentConfiguration();
}
void ChatConfigurationController::loadAvailableConfigurations()
{
auto &manager = Settings::ConfigurationManager::instance();
@@ -56,23 +107,56 @@ void ChatConfigurationController::loadAvailableConfigurations()
Settings::ConfigurationType::Chat);
m_availableConfigurations.clear();
m_agentIdByEntry.clear();
m_availableConfigurations.append(QObject::tr("Current Settings"));
for (const Settings::AIConfiguration &config : configs) {
m_availableConfigurations.append(config.name);
}
if (m_agents) {
for (const Acp::AgentDefinition &agent : m_agents->catalog().launchableAgents()) {
const QString entry = agentEntry(agent.name);
m_agentIdByEntry.insert(entry, agent.id);
m_availableConfigurations.append(entry);
}
}
updateCurrentConfiguration();
emit availableConfigurationsChanged();
}
std::optional<Acp::AgentDefinition> ChatConfigurationController::agentById(const QString &agentId)
{
if (agentId.isEmpty() || !m_agents)
return std::nullopt;
if (auto agent = m_agents->catalog().agent(agentId))
return agent;
m_agents->reload();
return m_agents->catalog().agent(agentId);
}
void ChatConfigurationController::applyConfiguration(const QString &configName)
{
if (configName == QObject::tr("Current Settings")) {
const QString agentId = m_agentIdByEntry.value(configName);
if (!agentId.isEmpty()) {
if (!m_agents)
return;
if (const auto agent = m_agents->catalog().agent(agentId))
emit agentRequested(*agent);
return;
}
if (configName == QObject::tr("Current Settings")) {
emit llmRequested();
return;
}
emit llmRequested();
auto &manager = Settings::ConfigurationManager::instance();
QVector<Settings::AIConfiguration> configs = manager.configurations(
Settings::ConfigurationType::Chat);
@@ -4,9 +4,18 @@
#pragma once
#include <optional>
#include <QHash>
#include <QObject>
#include <QStringList>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
class AgentCatalogStore;
}
namespace QodeAssist::Chat {
class ChatConfigurationController : public QObject
@@ -19,18 +28,30 @@ public:
QStringList availableConfigurations() const;
QString currentConfiguration() const;
void setAgentCatalog(Acp::AgentCatalogStore *store);
void loadAvailableConfigurations();
void applyConfiguration(const QString &configName);
void setBoundAgent(const Acp::AgentDefinition &agent);
void clearBoundAgent();
std::optional<Acp::AgentDefinition> agentById(const QString &agentId);
signals:
void availableConfigurationsChanged();
void currentConfigurationChanged();
void agentRequested(const QodeAssist::Acp::AgentDefinition &agent);
void llmRequested();
private:
void updateCurrentConfiguration();
Acp::AgentCatalogStore *m_agents = nullptr;
QStringList m_availableConfigurations;
QHash<QString, QString> m_agentIdByEntry;
QString m_currentConfiguration;
QString m_boundAgentName;
};
} // namespace QodeAssist::Chat
+189 -42
View File
@@ -8,15 +8,19 @@
#include <QFileInfo>
#include <QMimeDatabase>
#include "ChatHistoryBridge.hpp"
#include "ChatSerializer.hpp"
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <utils/filepath.h>
#include "ChatFileStore.hpp"
#include "LlmChatBackend.hpp"
#include "TurnContextAdapters.hpp"
#include "context/ChangesManager.h"
#include "context/RulesLoader.hpp"
#include "context/FileEditManager.hpp"
#include "logger/Logger.hpp"
#include "session/FileEditPayload.hpp"
#include "session/TurnContextBuilder.hpp"
#include "acp/AcpChatBackend.hpp"
#include "mcp/AgentKnowledgeServer.hpp"
#include "settings/ChatAssistantSettings.hpp"
namespace QodeAssist::Chat {
@@ -28,9 +32,42 @@ ChatController::ChatController(
, m_chatModel(chatModel)
, m_contextManager(new Context::ContextManager(this))
, m_session(new Session::Session(this))
, m_backend(new LlmChatBackend(promptProvider, this))
, m_llmBackend(new LlmChatBackend(promptProvider, this))
, m_acpBackend(new Acp::AcpChatBackend(this))
, m_agentKnowledge(new Mcp::AgentKnowledgeServer(this))
, m_backend(m_llmBackend)
{
new ChatHistoryBridge(m_session, chatModel, this);
connect(m_session, &Session::Session::rowsReset, m_chatModel, &ChatModel::resetMessages);
connect(m_session, &Session::Session::rowsAppended, m_chatModel, &ChatModel::appendMessages);
connect(m_session, &Session::Session::rowUpdated, m_chatModel, &ChatModel::updateMessage);
connect(m_session, &Session::Session::rowsRemoved, m_chatModel, &ChatModel::removeMessages);
m_chatModel->resetMessages(m_session->rows());
m_acpBackend->setStoredContentLoader(&ChatFileStore::loadRawContentFromStorage);
m_agentKnowledge->setIgnorePredicate([this](const QString &filePath) {
auto *project = ProjectExplorer::ProjectManager::projectForFile(
Utils::FilePath::fromString(filePath));
return m_contextManager->ignoreManager()->shouldIgnore(filePath, project);
});
m_acpBackend->setKnowledgeService(m_agentKnowledge);
connect(
m_session,
&Session::Session::sessionInfoReceived,
this,
&ChatController::sessionInfoReceived);
connect(
m_acpBackend,
&Acp::AcpChatBackend::agentSessionUnavailable,
this,
&ChatController::agentSessionUnavailable);
connect(
m_acpBackend,
&Acp::AcpChatBackend::availableCommandsChanged,
this,
&ChatController::agentCommandsChanged);
m_session->setBackend(m_backend);
@@ -39,7 +76,7 @@ ChatController::ChatController(
connect(m_session, &Session::Session::turnFinished, this, [this](const QString &turnId) {
QString applyError;
if (!Context::ChangesManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
if (!Context::FileEditManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
.arg(turnId, applyError));
}
@@ -56,14 +93,53 @@ ChatController::ChatController(
connect(m_session, &Session::Session::rowsReset, this, [this] { registerHistoricalEdits(); });
auto &changes = Context::ChangesManager::instance();
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &id) {
connect(
m_session,
&Session::Session::agentFileEditRecorded,
this,
[](const QString &turnId,
const QString &editId,
const QString &filePath,
const QString &oldContent,
const QString &newContent) {
const QFileInfo info(filePath);
const QString canonical = info.canonicalFilePath();
const Utils::FilePath target = Utils::FilePath::fromString(
canonical.isEmpty() ? info.absoluteFilePath() : canonical);
bool insideProject = false;
const QList<ProjectExplorer::Project *> projects
= ProjectExplorer::ProjectManager::projects();
for (const ProjectExplorer::Project *project : projects) {
if (target.isChildOf(project->projectDirectory())) {
insideProject = true;
break;
}
}
if (!insideProject) {
LOG_MESSAGE(
QString("Agent edit %1 targets %2 outside every open project; recorded "
"in the transcript only, without apply/undo actions")
.arg(editId, filePath));
return;
}
Context::FileEditManager::instance().registerAppliedFileEdit(
editId, target.toUrlishString(), oldContent, newContent, turnId);
});
auto &changes = Context::FileEditManager::instance();
connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &id) {
m_session->updateFileEditStatus(id, "applied", "Successfully applied");
});
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &id) {
connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &id) {
m_session->updateFileEditStatus(id, "rejected", "Rejected by user");
});
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &id) {
connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &id) {
recordFileEditStatus(id, "rejected", "Successfully undone");
});
connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &id) {
m_session->updateFileEditStatus(id, "archived", "Archived (from previous conversation turn)");
});
}
@@ -73,32 +149,107 @@ void ChatController::setSkillsManager(Skills::SkillsManager *skillsManager)
m_skillsManager = skillsManager;
}
void ChatController::bindAgent(const Acp::AgentDefinition &agent)
{
m_acpBackend->bindAgent(agent);
activateBackend(m_acpBackend);
}
void ChatController::bindLlm()
{
activateBackend(m_llmBackend);
}
QString ChatController::boundAgentId() const
{
return m_backend == m_acpBackend ? m_acpBackend->boundAgentId() : QString();
}
QString ChatController::boundAgentName() const
{
return m_backend == m_acpBackend ? m_acpBackend->boundAgentName() : QString();
}
QList<LLMQore::Acp::AvailableCommand> ChatController::agentCommands() const
{
if (m_backend != m_acpBackend)
return {};
return m_acpBackend->availableCommands();
}
bool ChatController::transcriptEmpty() const
{
return m_session->rows().isEmpty();
}
Acp::AgentBinding ChatController::agentBinding() const
{
if (m_backend != m_acpBackend)
return {};
return Acp::AgentBinding{m_acpBackend->boundAgentId(), m_acpBackend->bindingSessionId()};
}
void ChatController::resumeAgentSession(const QString &sessionId)
{
m_acpBackend->resumeSession(sessionId);
}
void ChatController::startFreshAgentSession()
{
m_acpBackend->startFreshSession();
}
void ChatController::startFreshAgentSession(const QString &handoverSummary)
{
m_acpBackend->clearToolSession(m_chatFilePath);
m_acpBackend->startFreshSession();
m_acpBackend->setHandoverSummary(handoverSummary);
}
void ChatController::releaseAgentSession()
{
m_acpBackend->clearToolSession(m_chatFilePath);
}
bool ChatController::conversationStarted() const
{
return !m_session->history().messages().isEmpty();
}
void ChatController::activateBackend(Session::ChatBackend *backend)
{
if (m_backend == backend)
return;
m_session->cancel();
if (m_backend)
m_backend->clearToolSession(m_chatFilePath);
m_backend = backend;
m_session->setBackend(backend);
backend->setChatFilePath(m_chatFilePath);
}
Session::Session *ChatController::session() const
{
return m_session;
}
void ChatController::sendMessage(
const QString &message,
const QList<QString> &attachments,
const QList<QString> &linkedFiles,
bool useTools,
bool useThinking)
void ChatController::sendMessage(const QString &message, const QList<QString> &attachments)
{
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
LOG_MESSAGE("Ignoring empty chat message");
return;
}
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
Context::FileEditManager::instance().archiveAllNonArchivedEdits();
m_session->sendTurn(
composeUserBlocks(message, attachments),
buildTurnContext(message, linkedFiles),
Session::TurnOptions{useTools, useThinking});
m_session->sendTurn(composeUserBlocks(message, attachments), buildTurnContext(message));
}
void ChatController::clearMessages()
void ChatController::clearConversation()
{
m_backend->clearToolSession(m_chatFilePath);
m_session->clear();
@@ -114,6 +265,11 @@ void ChatController::resetToRow(int rowIndex)
m_session->truncateRows(rowIndex);
}
void ChatController::respondToPermission(const QString &requestId, const QString &optionId)
{
m_session->respondPermission(requestId, optionId);
}
QList<Session::ContentBlock> ChatController::composeUserBlocks(
const QString &message, const QList<QString> &attachments)
{
@@ -132,7 +288,7 @@ QList<Session::ContentBlock> ChatController::composeUserBlocks(
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const auto &file : m_contextManager->getContentFiles(textFiles)) {
QString storedPath;
if (!ChatSerializer::saveContentToStorage(
if (!ChatFileStore::saveContentToStorage(
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
continue;
}
@@ -152,7 +308,7 @@ QList<Session::ContentBlock> ChatController::composeUserBlocks(
const QFileInfo fileInfo(imagePath);
QString storedPath;
if (!ChatSerializer::saveContentToStorage(
if (!ChatFileStore::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
continue;
}
@@ -169,32 +325,23 @@ QList<Session::ContentBlock> ChatController::composeUserBlocks(
return blocks;
}
std::optional<Session::TurnContext> ChatController::buildTurnContext(
const QString &message, const QList<QString> &linkedFiles) const
Session::TurnContext ChatController::buildTurnContext(const QString &message) const
{
auto &chatAssistantSettings = Settings::chatAssistantSettings();
if (!chatAssistantSettings.useSystemPrompt())
return std::nullopt;
Session::TurnContextRequest contextRequest;
contextRequest.message = message;
contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
contextRequest.linkedFilePaths = linkedFiles;
contextRequest.needs = m_backend->contextNeeds();
const QString lastRoleId = chatAssistantSettings.lastUsedRoleId();
if (!lastRoleId.isEmpty()) {
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
if (!role.id.isEmpty())
contextRequest.rolePrompt = role.systemPrompt;
}
if (contextRequest.needs.systemPrompt)
contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
auto *project = Context::RulesLoader::getActiveProject();
auto *project = activeProject();
ProjectContextQtCreator projectPort(project);
LinkedFilesQtCreator linkedFilesPort(m_contextManager);
auto skillsPort = makeSkillsContext(m_skillsManager, project);
const Session::TurnContextBuilder builder(projectPort, skillsPort.get(), linkedFilesPort);
const Session::TurnContextBuilder builder(projectPort, skillsPort.get());
return builder.build(contextRequest);
}
@@ -202,7 +349,7 @@ std::optional<Session::TurnContext> ChatController::buildTurnContext(
void ChatController::recordFileEditStatus(
const QString &editId, const QString &status, const QString &fallbackMessage)
{
const auto edit = Context::ChangesManager::instance().getFileEdit(editId);
const auto edit = Context::FileEditManager::instance().getFileEdit(editId);
const QString message = edit.statusMessage.isEmpty() ? fallbackMessage : edit.statusMessage;
m_session->updateFileEditStatus(editId, status, message);
}
@@ -232,7 +379,7 @@ void ChatController::registerHistoricalEdits()
continue;
}
Context::ChangesManager::instance().addFileEdit(
Context::FileEditManager::instance().addFileEdit(
editId,
filePath,
payload->value("old_content").toString(),
+41 -12
View File
@@ -7,7 +7,12 @@
#include <QObject>
#include <QString>
#include <LLMQore/AcpTypes.hpp>
#include "ChatModel.hpp"
#include "ConversationPorts.hpp"
#include "acp/AgentBinding.hpp"
#include "acp/AgentDefinition.hpp"
#include "session/Session.hpp"
#include "templates/IPromptProvider.hpp"
#include <context/ContextManager.hpp>
@@ -16,12 +21,19 @@ namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Acp {
class AcpChatBackend;
}
namespace QodeAssist::Mcp {
class AgentKnowledgeServer;
}
namespace QodeAssist::Chat {
class ChatHistoryBridge;
class LlmChatBackend;
class ChatController : public QObject
class ChatController : public QObject, public IConversationPort
{
Q_OBJECT
@@ -31,15 +43,10 @@ public:
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 clearMessages();
void sendMessage(const QString &message, const QList<QString> &attachments = {});
void cancelRequest();
void resetToRow(int rowIndex);
void respondToPermission(const QString &requestId, const QString &optionId);
Session::Session *session() const;
Context::ContextManager *contextManager() const;
@@ -47,7 +54,26 @@ public:
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
QString boundAgentId() const override;
QString boundAgentName() const;
QList<LLMQore::Acp::AvailableCommand> agentCommands() const;
bool conversationStarted() const override;
bool transcriptEmpty() const override;
Acp::AgentBinding agentBinding() const override;
void bindAgent(const Acp::AgentDefinition &agent) override;
void bindLlm() override;
void clearConversation() override;
void resumeAgentSession(const QString &sessionId) override;
void startFreshAgentSession() override;
void startFreshAgentSession(const QString &handoverSummary) override;
void releaseAgentSession() override;
signals:
void sessionInfoReceived(const QString &title);
void agentCommandsChanged();
void agentSessionUnavailable(const QString &reason);
void errorOccurred(const QString &error);
void messageReceivedCompletely();
void requestStarted(const QString &requestId);
@@ -57,11 +83,11 @@ signals:
private:
QList<Session::ContentBlock> composeUserBlocks(
const QString &message, const QList<QString> &attachments);
std::optional<Session::TurnContext> buildTurnContext(
const QString &message, const QList<QString> &linkedFiles) const;
Session::TurnContext buildTurnContext(const QString &message) const;
void recordFileEditStatus(
const QString &editId, const QString &status, const QString &fallbackMessage);
void registerHistoricalEdits();
void activateBackend(Session::ChatBackend *backend);
bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const;
@@ -72,7 +98,10 @@ private:
Context::ContextManager *m_contextManager = nullptr;
Skills::SkillsManager *m_skillsManager = nullptr;
Session::Session *m_session = nullptr;
LlmChatBackend *m_backend = nullptr;
LlmChatBackend *m_llmBackend = nullptr;
Acp::AcpChatBackend *m_acpBackend = nullptr;
Mcp::AgentKnowledgeServer *m_agentKnowledge = nullptr;
Session::ChatBackend *m_backend = nullptr;
QString m_chatFilePath;
};
+429
View File
@@ -0,0 +1,429 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatFileStore.hpp"
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QSaveFile>
#include <QUrl>
#include <QUuid>
#include <coreplugin/icore.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "session/HistorySerializer.hpp"
#include "session/Session.hpp"
namespace QodeAssist::Chat {
ChatFileStore::ChatFileStore(Session::Session *session, QObject *parent)
: QObject(parent)
, m_session(session)
{}
QString ChatFileStore::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 ChatFileStore::suggestedFileName() const
{
QString shortMessage;
if (!m_session)
return generateChatFileName(shortMessage, historyDir());
const QList<Session::MessageRow> &rows = m_session->rows();
if (!rows.isEmpty()) {
shortMessage = rows.first().content.split('\n').first().simplified().left(30);
if (shortMessage.isEmpty() && !rows.first().images.isEmpty())
shortMessage = "image_chat";
}
return generateChatFileName(shortMessage, historyDir());
}
QString ChatFileStore::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 ChatFileStore::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");
}
void ChatFileStore::setBindingReader(BindingReader reader)
{
m_bindingReader = std::move(reader);
}
void ChatFileStore::setBindingWriter(BindingWriter writer)
{
m_bindingWriter = std::move(writer);
}
SerializationResult ChatFileStore::save(const QString &filePath) const
{
if (!m_session)
return {false, QString("Chat session is no longer available")};
const Acp::AgentBinding binding = m_bindingReader ? m_bindingReader() : Acp::AgentBinding{};
return saveToFile(m_session->history(), binding, filePath);
}
SerializationResult ChatFileStore::load(const QString &filePath) const
{
if (!m_session)
return {false, QString("Chat session is no longer available")};
Session::ConversationHistory history;
Acp::AgentBinding binding;
const SerializationResult result = loadFromFile(history, binding, filePath);
if (!result.success)
return result;
m_session->setHistory(history);
if (m_bindingWriter)
m_bindingWriter(binding);
return result;
}
void ChatFileStore::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 ChatFileStore::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 ChatFileStore::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 ChatFileStore::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;
}
SerializationResult ChatFileStore::saveToFile(
const Session::ConversationHistory &history,
const Acp::AgentBinding &binding,
const QString &filePath)
{
if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"};
}
QJsonObject root = Session::HistorySerializer::toJson(history);
if (!binding.isEmpty())
root["agent"] = binding.toJson();
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
}
if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
}
if (!file.commit()) {
return {false, QString("Failed to save file: %1").arg(file.errorString())};
}
return {true, QString()};
}
SerializationResult ChatFileStore::loadFromFile(
Session::ConversationHistory &history, Acp::AgentBinding &binding, 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())};
}
const QJsonObject root = doc.object();
const QString version = root["version"].toString();
if (!Session::HistorySerializer::isSupportedVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)};
}
int droppedBlocks = 0;
const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
if (!loaded) {
return {false, QString("Failed to read chat history from: %1").arg(filePath)};
}
if (version != Session::HistorySerializer::currentVersion()) {
LOG_MESSAGE(QString("Converted chat from format %1 to %2")
.arg(version, Session::HistorySerializer::currentVersion()));
}
history = *loaded;
QString bindingError;
binding = Acp::AgentBinding::fromJson(root["agent"], &bindingError);
if (!bindingError.isEmpty()) {
const QString warning
= QString("This chat records which agent held it, but %1, so it opens unbound")
.arg(bindingError);
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
return {true, QString(), warning};
}
if (droppedBlocks > 0) {
const QString warning
= QString(
"%1 message part(s) in this chat could not be read and will be lost if "
"the chat is saved again")
.arg(droppedBlocks);
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
return {true, QString(), warning};
}
return {true, QString(), QString()};
}
bool ChatFileStore::ensureDirectoryExists(const QString &filePath)
{
QFileInfo fileInfo(filePath);
QDir dir = fileInfo.dir();
return dir.exists() || dir.mkpath(".");
}
QString ChatFileStore::getChatContentFolder(const QString &chatFilePath)
{
QFileInfo fileInfo(chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
return QDir(dirPath).filePath(baseName + "_content");
}
bool ChatFileStore::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;
}
QByteArray ChatFileStore::loadRawContentFromStorage(
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 QByteArray();
}
QByteArray contentData = file.readAll();
file.close();
return contentData;
}
QString ChatFileStore::loadContentFromStorage(
const QString &chatFilePath, const QString &storedPath)
{
return QString::fromLatin1(loadRawContentFromStorage(chatFilePath, storedPath).toBase64());
}
} // namespace QodeAssist::Chat
+89
View File
@@ -0,0 +1,89 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <QObject>
#include <QPointer>
#include <QString>
#include "acp/AgentBinding.hpp"
#include "session/ConversationHistory.hpp"
namespace QodeAssist::Session {
class Session;
}
namespace QodeAssist::Chat {
struct SerializationResult
{
bool success{false};
QString errorMessage;
QString warningMessage;
};
class ChatFileStore : public QObject
{
Q_OBJECT
public:
explicit ChatFileStore(Session::Session *session, 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;
using BindingReader = std::function<Acp::AgentBinding()>;
using BindingWriter = std::function<void(const Acp::AgentBinding &)>;
void setBindingReader(BindingReader reader);
void setBindingWriter(BindingWriter writer);
SerializationResult save(const QString &filePath) const;
SerializationResult load(const QString &filePath) const;
void showSaveDialog();
void showLoadDialog();
void openHistoryFolder() const;
static SerializationResult saveToFile(
const Session::ConversationHistory &history,
const Acp::AgentBinding &binding,
const QString &filePath);
static SerializationResult loadFromFile(
Session::ConversationHistory &history,
Acp::AgentBinding &binding,
const QString &filePath);
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);
static QByteArray loadRawContentFromStorage(
const QString &chatFilePath, const QString &storedPath);
signals:
void saveRequested(const QString &filePath);
void loadRequested(const QString &filePath);
private:
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
static bool ensureDirectoryExists(const QString &filePath);
QPointer<Session::Session> m_session;
BindingReader m_bindingReader;
BindingWriter m_bindingWriter;
};
} // namespace QodeAssist::Chat
-107
View File
@@ -1,107 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatHistoryBridge.hpp"
#include "ChatModel.hpp"
#include "session/Session.hpp"
namespace QodeAssist::Chat {
namespace {
ChatModel::ChatRole toChatRole(Session::RowKind kind)
{
switch (kind) {
case Session::RowKind::System:
return ChatModel::ChatRole::System;
case Session::RowKind::User:
return ChatModel::ChatRole::User;
case Session::RowKind::Assistant:
return ChatModel::ChatRole::Assistant;
case Session::RowKind::Tool:
return ChatModel::ChatRole::Tool;
case Session::RowKind::FileEdit:
return ChatModel::ChatRole::FileEdit;
case Session::RowKind::Thinking:
return ChatModel::ChatRole::Thinking;
}
return ChatModel::ChatRole::Assistant;
}
ChatModel::Message toChatMessage(const Session::MessageRow &row)
{
ChatModel::Message message;
message.role = toChatRole(row.kind);
message.content = row.content;
message.id = row.id;
message.isRedacted = row.redacted;
message.signature = row.signature;
message.toolName = row.toolName;
message.toolArguments = row.toolArguments;
message.toolResult = row.toolResult;
for (const Session::AttachmentBlock &attachment : row.attachments)
message.attachments.append(Context::ContentFile{attachment.fileName, attachment.storedPath});
for (const Session::ImageBlock &image : row.images)
message.images.append(
ChatModel::ImageAttachment{image.fileName, image.storedPath, image.mediaType});
message.promptTokens = row.usage.promptTokens;
message.completionTokens = row.usage.completionTokens;
message.cachedPromptTokens = row.usage.cachedPromptTokens;
message.reasoningTokens = row.usage.reasoningTokens;
return message;
}
QVector<ChatModel::Message> toChatMessages(const QList<Session::MessageRow> &rows)
{
QVector<ChatModel::Message> messages;
messages.reserve(rows.size());
for (const Session::MessageRow &row : rows)
messages.append(toChatMessage(row));
return messages;
}
} // namespace
ChatHistoryBridge::ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent)
: QObject(parent)
, m_model(model)
{
connect(session, &Session::Session::rowsReset, this, &ChatHistoryBridge::onRowsReset);
connect(session, &Session::Session::rowsAppended, this, &ChatHistoryBridge::onRowsAppended);
connect(session, &Session::Session::rowUpdated, this, &ChatHistoryBridge::onRowUpdated);
connect(session, &Session::Session::rowsRemoved, this, &ChatHistoryBridge::onRowsRemoved);
onRowsReset(session->rows());
}
void ChatHistoryBridge::onRowsReset(const QList<Session::MessageRow> &rows)
{
if (m_model)
m_model->resetMessages(toChatMessages(rows));
}
void ChatHistoryBridge::onRowsAppended(const QList<Session::MessageRow> &rows)
{
if (m_model)
m_model->appendMessages(toChatMessages(rows));
}
void ChatHistoryBridge::onRowUpdated(int index, const Session::MessageRow &row)
{
if (m_model)
m_model->updateMessage(index, toChatMessage(row));
}
void ChatHistoryBridge::onRowsRemoved(int first, int count)
{
if (m_model)
m_model->removeMessages(first, count);
}
} // namespace QodeAssist::Chat
-37
View File
@@ -1,37 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QList>
#include <QObject>
#include <QPointer>
#include "session/HistoryProjection.hpp"
namespace QodeAssist::Session {
class Session;
}
namespace QodeAssist::Chat {
class ChatModel;
class ChatHistoryBridge : public QObject
{
Q_OBJECT
public:
ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent = nullptr);
private:
void onRowsReset(const QList<Session::MessageRow> &rows);
void onRowsAppended(const QList<Session::MessageRow> &rows);
void onRowUpdated(int index, const Session::MessageRow &row);
void onRowsRemoved(int first, int count);
QPointer<ChatModel> m_model;
};
} // namespace QodeAssist::Chat
-236
View File
@@ -1,236 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatHistoryStore.hpp"
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QRegularExpression>
#include <QUrl>
#include <coreplugin/icore.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "session/Session.hpp"
namespace QodeAssist::Chat {
ChatHistoryStore::ChatHistoryStore(Session::Session *session, QObject *parent)
: QObject(parent)
, m_session(session)
{}
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_session)
return generateChatFileName(shortMessage, historyDir());
const QList<Session::MessageRow> &rows = m_session->rows();
if (!rows.isEmpty()) {
shortMessage = rows.first().content.split('\n').first().simplified().left(30);
if (shortMessage.isEmpty() && !rows.first().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
{
if (!m_session)
return {false, QString("Chat session is no longer available")};
return ChatSerializer::saveToFile(m_session->history(), filePath);
}
SerializationResult ChatHistoryStore::load(const QString &filePath) const
{
if (!m_session)
return {false, QString("Chat session is no longer available")};
Session::ConversationHistory history;
const SerializationResult result = ChatSerializer::loadFromFile(history, filePath);
if (result.success)
m_session->setHistory(history);
return result;
}
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
-51
View File
@@ -1,51 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include "ChatSerializer.hpp"
namespace QodeAssist::Session {
class Session;
}
namespace QodeAssist::Chat {
class ChatHistoryStore : public QObject
{
Q_OBJECT
public:
explicit ChatHistoryStore(Session::Session *session, 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;
QPointer<Session::Session> m_session;
};
} // namespace QodeAssist::Chat
+67 -39
View File
@@ -10,7 +10,6 @@
#include <QUrl>
#include <algorithm>
#include <tuple>
#include "logger/Logger.hpp"
@@ -18,19 +17,33 @@ namespace QodeAssist::Chat {
namespace {
auto usageOf(const ChatModel::Message &message)
ChatModel::ChatRole toChatRole(Session::RowKind kind)
{
return std::tie(
message.promptTokens,
message.completionTokens,
message.cachedPromptTokens,
message.reasoningTokens);
switch (kind) {
case Session::RowKind::System:
return ChatModel::ChatRole::System;
case Session::RowKind::User:
return ChatModel::ChatRole::User;
case Session::RowKind::Assistant:
return ChatModel::ChatRole::Assistant;
case Session::RowKind::Tool:
case Session::RowKind::AgentTool:
return ChatModel::ChatRole::Tool;
case Session::RowKind::FileEdit:
return ChatModel::ChatRole::FileEdit;
case Session::RowKind::Thinking:
return ChatModel::ChatRole::Thinking;
case Session::RowKind::Permission:
return ChatModel::ChatRole::Permission;
case Session::RowKind::Plan:
return ChatModel::ChatRole::Plan;
}
return ChatModel::ChatRole::Assistant;
}
bool carriesUsage(const ChatModel::Message &message)
bool carriesUsage(const Session::MessageRow &row)
{
return message.promptTokens != 0 || message.completionTokens != 0
|| message.cachedPromptTokens != 0 || message.reasoningTokens != 0;
return !row.usage.isEmpty();
}
} // namespace
@@ -49,10 +62,10 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
if (!index.isValid() || index.row() >= m_messages.size())
return QVariant();
const Message &message = m_messages[index.row()];
const Session::MessageRow &message = m_messages[index.row()];
switch (static_cast<Roles>(role)) {
case Roles::RoleType:
return QVariant::fromValue(message.role);
return QVariant::fromValue(toChatRole(message.kind));
case Roles::Content: {
return message.content;
}
@@ -60,37 +73,47 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
QVariantList attachmentsList;
for (const auto &attachment : message.attachments) {
QVariantMap attachmentMap;
attachmentMap["fileName"] = attachment.filename;
attachmentMap["storedPath"] = attachment.content;
attachmentMap["fileName"] = attachment.fileName;
attachmentMap["storedPath"] = attachment.storedPath;
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);
QString fullPath = QDir(contentFolder).filePath(attachment.storedPath);
attachmentMap["filePath"] = fullPath;
} else {
attachmentMap["filePath"] = QString();
}
attachmentsList.append(attachmentMap);
}
return attachmentsList;
}
case Roles::IsRedacted: {
return message.isRedacted;
return message.redacted;
}
case Roles::PromptTokens:
return message.promptTokens;
return message.usage.promptTokens;
case Roles::CompletionTokens:
return message.completionTokens;
return message.usage.completionTokens;
case Roles::CachedPromptTokens:
return message.cachedPromptTokens;
return message.usage.cachedPromptTokens;
case Roles::ReasoningTokens:
return message.reasoningTokens;
return message.usage.reasoningTokens;
case Roles::TotalTokens:
return message.promptTokens + message.completionTokens;
return message.usage.promptTokens + message.usage.completionTokens;
case Roles::ToolKind:
return message.toolKind;
case Roles::ToolStatus:
return message.toolStatus;
case Roles::ToolName:
return message.toolName;
case Roles::ToolResult:
return message.toolResult;
case Roles::ToolDetails:
return QVariant::fromValue(message.toolDetails);
case Roles::Images: {
QVariantList imagesList;
for (const auto &image : message.images) {
@@ -98,7 +121,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
imageMap["fileName"] = image.fileName;
imageMap["storedPath"] = image.storedPath;
imageMap["mediaType"] = image.mediaType;
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
@@ -111,7 +134,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
imageMap["imageUrl"] = QString();
imageMap["filePath"] = QString();
}
imagesList.append(imageMap);
}
return imagesList;
@@ -134,32 +157,37 @@ QHash<int, QByteArray> ChatModel::roleNames() const
roles[Roles::CachedPromptTokens] = "cachedPromptTokens";
roles[Roles::ReasoningTokens] = "reasoningTokens";
roles[Roles::TotalTokens] = "totalTokens";
roles[Roles::ToolKind] = "toolKind";
roles[Roles::ToolStatus] = "toolStatus";
roles[Roles::ToolDetails] = "toolDetails";
roles[Roles::ToolName] = "toolName";
roles[Roles::ToolResult] = "toolResult";
return roles;
}
void ChatModel::resetMessages(const QVector<Message> &messages)
void ChatModel::resetMessages(const QList<Session::MessageRow> &rows)
{
beginResetModel();
m_messages = messages;
m_messages = rows;
endResetModel();
emit modelReseted();
emit sessionUsageChanged();
}
void ChatModel::appendMessages(const QVector<Message> &messages)
void ChatModel::appendMessages(const QList<Session::MessageRow> &rows)
{
if (messages.isEmpty())
if (rows.isEmpty())
return;
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + messages.size() - 1);
m_messages.append(messages);
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + rows.size() - 1);
m_messages.append(rows);
endInsertRows();
if (std::any_of(messages.cbegin(), messages.cend(), carriesUsage))
if (std::any_of(rows.cbegin(), rows.cend(), carriesUsage))
emit sessionUsageChanged();
}
void ChatModel::updateMessage(int index, const Message &message)
void ChatModel::updateMessage(int index, const Session::MessageRow &row)
{
if (index < 0 || index >= m_messages.size()) {
LOG_MESSAGE(QString("Session/model desync: update of row %1 with %2 rows present")
@@ -168,8 +196,8 @@ void ChatModel::updateMessage(int index, const Message &message)
return;
}
const bool usageChanged = usageOf(m_messages[index]) != usageOf(message);
m_messages[index] = message;
const bool usageChanged = m_messages[index].usage != row.usage;
m_messages[index] = row;
emit dataChanged(this->index(index), this->index(index));
if (usageChanged)
@@ -262,7 +290,7 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const
QVariantList result;
const int limit = maxLength > 4 ? maxLength : 80;
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role != ChatRole::User)
if (m_messages[i].kind != Session::RowKind::User)
continue;
QString preview = m_messages[i].content;
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
@@ -283,7 +311,7 @@ int ChatModel::sessionPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.promptTokens;
total += m.usage.promptTokens;
return total;
}
@@ -291,7 +319,7 @@ int ChatModel::sessionCompletionTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.completionTokens;
total += m.usage.completionTokens;
return total;
}
@@ -299,7 +327,7 @@ int ChatModel::sessionCachedPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
total += m.cachedPromptTokens;
total += m.usage.cachedPromptTokens;
return total;
}
+12 -37
View File
@@ -7,10 +7,9 @@
#include "MessagePart.hpp"
#include <QAbstractListModel>
#include <QJsonObject>
#include <QtQmlIntegration>
#include "context/ContentFile.hpp"
#include "session/HistoryProjection.hpp"
namespace QodeAssist::Chat {
@@ -24,7 +23,7 @@ class ChatModel : public QAbstractListModel
QML_ELEMENT
public:
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking, Permission, Plan };
Q_ENUM(ChatRole)
enum Roles {
@@ -37,47 +36,24 @@ public:
CompletionTokens,
CachedPromptTokens,
ReasoningTokens,
TotalTokens
TotalTokens,
ToolKind,
ToolStatus,
ToolDetails,
ToolName,
ToolResult
};
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;
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);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
void resetMessages(const QVector<Message> &messages);
void appendMessages(const QVector<Message> &messages);
void updateMessage(int index, const Message &message);
void resetMessages(const QList<Session::MessageRow> &rows);
void appendMessages(const QList<Session::MessageRow> &rows);
void updateMessage(int index, const Session::MessageRow &row);
void removeMessages(int first, int count);
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
@@ -96,10 +72,9 @@ signals:
void sessionUsageChanged();
private:
QVector<Message> m_messages;
QList<Session::MessageRow> m_messages;
QString m_chatFilePath;
};
} // namespace QodeAssist::Chat
Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message)
Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart)
File diff suppressed because it is too large Load Diff
+59 -90
View File
@@ -9,10 +9,11 @@
#include <QVariantList>
#include "ChatController.hpp"
#include "ChatFileManager.hpp"
#include "AttachmentStaging.hpp"
#include "ChatModel.hpp"
#include "ConversationCoordinator.hpp"
#include "templates/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include <utils/id.h>
namespace QodeAssist::Skills {
class SkillsManager;
@@ -21,21 +22,21 @@ class SkillsManager;
namespace QodeAssist::Chat {
class ChatCompressor;
class AgentRoleController;
class ChatConfigurationController;
class FileEditController;
class InputTokenCounter;
class ChatHistoryStore;
class ChatFileStore;
class SessionFileRegistry;
class ChatRootView : public QQuickItem
class ChatRootView : public QQuickItem,
private IAgentCatalogPort,
private ICompressionPort,
private ISendPort
{
Q_OBJECT
Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL)
Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL)
Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL)
Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL)
Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL)
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)
@@ -46,24 +47,25 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL)
Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL)
Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL)
Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL)
Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL)
Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL)
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
Q_PROPERTY(bool isAgentBound READ isAgentBound NOTIFY isAgentBoundChanged FINAL)
Q_PROPERTY(QString agentSessionIssue READ agentSessionIssue NOTIFY agentSessionIssueChanged FINAL)
Q_PROPERTY(bool canStartNewAgentSession READ canStartNewAgentSession NOTIFY
agentSessionIssueChanged FINAL)
Q_PROPERTY(bool canHandOverSummary READ canHandOverSummary NOTIFY agentSessionIssueChanged FINAL)
Q_PROPERTY(QString summaryHandoverTooltip READ summaryHandoverTooltip NOTIFY
agentSessionIssueChanged FINAL)
Q_PROPERTY(bool canShrinkContext READ canShrinkContext NOTIFY shrinkContextStateChanged FINAL)
Q_PROPERTY(QString shrinkContextTooltip READ shrinkContextTooltip NOTIFY
shrinkContextStateChanged 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)
@@ -88,23 +90,17 @@ public:
QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const;
QStringList attachmentFiles() const;
QStringList linkedFiles() const;
Q_INVOKABLE void showAttachFilesDialog();
Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths);
Q_INVOKABLE void removeFileFromAttachList(int index);
Q_INVOKABLE void showLinkFilesDialog();
Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths);
Q_INVOKABLE void removeFileFromLinkList(int index);
Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const;
Q_INVOKABLE void showAddImageDialog();
Q_INVOKABLE bool isImageFile(const QString &filePath) const;
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
Q_INVOKABLE bool isSendShortcut(int key, int modifiers) const;
QString sendShortcutText() const;
Q_INVOKABLE void setIsSyncOpenFiles(bool state);
Q_INVOKABLE void openChatHistoryFolder();
Q_INVOKABLE void openRulesFolder();
Q_INVOKABLE void openSettings();
Q_INVOKABLE void openFileInEditor(const QString &filePath);
@@ -117,16 +113,9 @@ public:
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;
@@ -139,18 +128,10 @@ public:
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;
Q_INVOKABLE QVariantList searchSlashCommands(const QString &query) const;
bool useTools() const;
void setUseTools(bool enabled);
bool useThinking() const;
void setUseThinking(bool enabled);
Q_INVOKABLE void respondToPermission(const QString &requestId, const QString &optionId);
Q_INVOKABLE void applyFileEdit(const QString &editId);
Q_INVOKABLE void rejectFileEdit(const QString &editId);
@@ -163,21 +144,16 @@ public:
Q_INVOKABLE void loadAvailableConfigurations();
Q_INVOKABLE void applyConfiguration(const QString &configName);
Q_INVOKABLE void confirmChatTargetSwitch();
Q_INVOKABLE void cancelChatTargetSwitch();
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;
@@ -185,8 +161,16 @@ public:
QString lastInfoMessage() const;
bool isThinkingSupport() const;
bool isAgentBound() const;
QString agentSessionIssue() const;
bool canStartNewAgentSession() const;
bool canHandOverSummary() const;
QString summaryHandoverTooltip() const;
bool canShrinkContext() const;
QString shrinkContextTooltip() const;
Q_INVOKABLE void startNewAgentSession();
Q_INVOKABLE void startNewAgentSessionWithSummary();
bool isCompressing() const;
bool isInEditor() const;
@@ -201,7 +185,6 @@ public slots:
void copyToClipboard(const QString &text);
void cancelRequest();
void clearAttachmentFiles();
void clearLinkedFiles();
void clearMessages();
void resetChatToMessage(int index);
@@ -209,9 +192,7 @@ signals:
void chatModelChanged();
void currentTemplateChanged();
void attachmentFilesChanged();
void linkedFilesChanged();
void inputTokensCountChanged();
void isSyncOpenFilesChanged();
void chatFileNameChanged();
void textFamilyChanged();
void codeFamilyChanged();
@@ -224,19 +205,17 @@ signals:
void lastErrorMessageChanged();
void lastInfoMessageChanged();
void sendShortcutTextChanged();
void activeRulesChanged();
void activeRulesCountChanged();
void useToolsChanged();
void useThinkingChanged();
void currentMessageEditsStatsChanged();
void isThinkingSupportChanged();
void isAgentBoundChanged();
void agentSessionIssueChanged();
void shrinkContextStateChanged();
void slashCommandsChanged();
void availableConfigurationsChanged();
void currentConfigurationChanged();
void chatTargetSwitchNeedsNewChat(const QString &targetName);
void availableAgentRolesChanged();
void currentAgentRoleChanged();
void baseSystemPromptChanged();
void isCompressingChanged();
@@ -246,69 +225,59 @@ signals:
void isInEditorChanged();
void chatTitleChanged();
void openFilesChanged();
void closeHostRequested();
protected:
void componentComplete() override;
private:
QVariantList searchSkills(const QString &query) const;
QVariantList searchAgentCommands(const QString &query) const;
QString computeChatTitle() const;
void triggerOpenChatCommand(Utils::Id commandId);
void handOffSession();
bool deferSendForAutoCompress(
const QString &message,
const QStringList &attachments,
const QStringList &linkedFiles,
bool useTools,
bool useThinking);
void dispatchSend(
const QString &message,
const QStringList &attachments,
const QStringList &linkedFiles,
bool useTools,
bool useThinking);
bool hasImageAttachments(const QStringList &attachments) const;
std::optional<Acp::AgentDefinition> agentById(const QString &agentId) const override;
QString compressionConfigurationIssue() const override;
bool isCompressionRunning() const override;
void startTranscriptSummary() override;
void startCompression() override;
bool autoCompressEnabled() const override;
int autoCompressThreshold() const override;
int estimatedNextTokens() const override;
bool prepareChatFileForCompression(
const QString &message, const QStringList &attachments) override;
void dispatch(const QString &message, const QStringList &attachments) override;
SessionFileRegistry *sessionFileRegistry() const;
Skills::SkillsManager *skillsManager() const;
ChatModel *m_chatModel;
Templates::PromptProviderChat m_promptProvider;
ChatController *m_controller;
ChatFileManager *m_fileManager;
AttachmentStaging *m_attachmentStaging;
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;
ChatFileStore *m_historyStore;
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
mutable bool m_sessionFileRegistryResolved = false;
mutable QPointer<Skills::SkillsManager> m_skillsManager;
mutable bool m_skillsManagerResolved = false;
ConversationCoordinator *m_coordinator = nullptr;
};
} // namespace QodeAssist::Chat
-169
View File
@@ -1,169 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatSerializer.hpp"
#include "Logger.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QUuid>
#include "session/HistorySerializer.hpp"
namespace QodeAssist::Chat {
SerializationResult ChatSerializer::saveToFile(
const Session::ConversationHistory &history, const QString &filePath)
{
if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"};
}
const QJsonObject root = Session::HistorySerializer::toJson(history);
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
}
if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
}
if (!file.commit()) {
return {false, QString("Failed to save file: %1").arg(file.errorString())};
}
return {true, QString()};
}
SerializationResult ChatSerializer::loadFromFile(
Session::ConversationHistory &history, 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())};
}
const QJsonObject root = doc.object();
const QString version = root["version"].toString();
if (!Session::HistorySerializer::isSupportedVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)};
}
int droppedBlocks = 0;
const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
if (!loaded) {
return {false, QString("Failed to read chat history from: %1").arg(filePath)};
}
if (version != Session::HistorySerializer::currentVersion()) {
LOG_MESSAGE(QString("Converted chat from format %1 to %2")
.arg(version, Session::HistorySerializer::currentVersion()));
}
history = *loaded;
if (droppedBlocks > 0) {
const QString warning
= QString(
"%1 message part(s) in this chat could not be read and will be lost if "
"the chat is saved again")
.arg(droppedBlocks);
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
return {true, QString(), warning};
}
return {true, QString(), QString()};
}
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
{
QFileInfo fileInfo(filePath);
QDir dir = fileInfo.dir();
return dir.exists() || dir.mkpath(".");
}
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
-40
View File
@@ -1,40 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
#include "session/ConversationHistory.hpp"
namespace QodeAssist::Chat {
struct SerializationResult
{
bool success{false};
QString errorMessage;
QString warningMessage;
};
class ChatSerializer
{
public:
static SerializationResult saveToFile(
const Session::ConversationHistory &history, const QString &filePath);
static SerializationResult loadFromFile(
Session::ConversationHistory &history, const QString &filePath);
// 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 bool ensureDirectoryExists(const QString &filePath);
};
} // namespace QodeAssist::Chat
@@ -0,0 +1,373 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ConversationCoordinator.hpp"
#include "logger/Logger.hpp"
namespace QodeAssist::Chat {
ConversationCoordinator::ConversationCoordinator(const Ports &ports, QObject *parent)
: QObject(parent)
, m_ports(ports)
{}
void ConversationCoordinator::requestSend(const QString &message, const QStringList &attachments)
{
if (refuseWhileReadOnly())
return;
if (!m_ports.ops->boundAgentId().isEmpty()
&& m_ports.compression->isCompressionRunning()) {
emit errorSurfaced(
tr("A handover summary is being prepared; wait for it to finish before sending"));
return;
}
if (deferSendForAutoCompress(message, attachments))
return;
m_ports.send->dispatch(message, attachments);
}
bool ConversationCoordinator::refuseWhileReadOnly()
{
if (m_sessionIssue.isEmpty())
return false;
emit errorSurfaced(m_sessionIssue);
return true;
}
bool ConversationCoordinator::hasDeferredSend() const
{
return m_deferredSend.active;
}
bool ConversationCoordinator::deferSendForAutoCompress(
const QString &message, const QStringList &attachments)
{
if (!m_ports.ops->boundAgentId().isEmpty())
return false;
if (!m_ports.send->autoCompressEnabled())
return false;
const int threshold = m_ports.send->autoCompressThreshold();
const int inputTokens = m_ports.send->estimatedNextTokens();
if (inputTokens < threshold)
return false;
if (!m_ports.send->prepareChatFileForCompression(message, attachments))
return false;
if (m_ports.compression->isCompressionRunning() || m_deferredSend.active)
return false;
LOG_MESSAGE(QString("Auto-compress preempt: estimated next=%1 ≥ threshold=%2; deferring send")
.arg(inputTokens)
.arg(threshold));
m_deferredSend = {message, attachments, true};
m_ports.compression->startCompression();
return true;
}
void ConversationCoordinator::compressionSettled()
{
if (!m_deferredSend.active)
return;
const DeferredSend deferred = m_deferredSend;
m_deferredSend = {};
if (refuseWhileReadOnly())
return;
m_ports.send->dispatch(deferred.message, deferred.attachments);
}
void ConversationCoordinator::chooseAgent(const Acp::AgentDefinition &agent)
{
if (m_ports.ops->boundAgentId() == agent.id)
return;
if (m_ports.ops->conversationStarted()) {
m_pendingAgent = agent;
m_pendingLlmSwitch = false;
emit switchConfirmationNeeded(agent.name);
return;
}
bindAgentNow(agent);
}
void ConversationCoordinator::chooseLlm()
{
if (m_ports.ops->boundAgentId().isEmpty())
return;
if (m_ports.ops->conversationStarted()) {
m_pendingAgent.reset();
m_pendingLlmSwitch = true;
emit switchConfirmationNeeded(tr("direct LLM chat"));
return;
}
bindLlmNow();
}
void ConversationCoordinator::confirmSwitch()
{
const auto agent = m_pendingAgent;
const bool toLlm = m_pendingLlmSwitch;
m_pendingAgent.reset();
m_pendingLlmSwitch = false;
if (!agent && !toLlm)
return;
m_ports.ops->clearConversation();
if (agent)
bindAgentNow(*agent);
else
bindLlmNow();
}
void ConversationCoordinator::cancelSwitch()
{
m_pendingAgent.reset();
m_pendingLlmSwitch = false;
emit switchCancelled();
}
bool ConversationCoordinator::switchPending() const
{
return m_pendingAgent.has_value() || m_pendingLlmSwitch;
}
void ConversationCoordinator::bindAgentNow(const Acp::AgentDefinition &agent)
{
m_ports.ops->bindAgent(agent);
emit boundToAgent(agent);
}
void ConversationCoordinator::bindLlmNow()
{
m_ports.ops->bindLlm();
emit boundToLlm();
}
void ConversationCoordinator::restoreAgentBinding(const Acp::AgentBinding &binding)
{
setSessionIssue(QString(), false);
m_quarantinedBinding = {};
m_ports.ops->releaseAgentSession();
if (binding.isEmpty()) {
bindLlmNow();
return;
}
const auto agent = m_ports.catalog->agentById(binding.agentId);
if (!agent || !agent->isLaunchable()) {
m_quarantinedBinding = binding;
bindLlmNow();
setSessionIssue(
tr("This chat was held with the agent \"%1\", which is not available any more. "
"The transcript is read-only, and the agent it names is kept so the chat can be "
"continued once that agent is installed again.")
.arg(binding.displayId()),
false);
return;
}
bindAgentNow(*agent);
if (!binding.sessionId.isEmpty()) {
m_ports.ops->resumeAgentSession(binding.sessionId);
return;
}
if (m_ports.ops->conversationStarted()) {
setSessionIssue(
tr("This chat records the agent \"%1\" but not a session to reopen, so the transcript "
"is read-only. Start a new session to keep working with this agent — it will not "
"have the context above.")
.arg(binding.displayId()),
true);
}
}
Acp::AgentBinding ConversationCoordinator::bindingForSave() const
{
return m_quarantinedBinding.isEmpty() ? m_ports.ops->agentBinding() : m_quarantinedBinding;
}
QString ConversationCoordinator::sessionIssue() const
{
return m_sessionIssue;
}
bool ConversationCoordinator::readOnly() const
{
return !m_sessionIssue.isEmpty();
}
bool ConversationCoordinator::canStartFreshSession() const
{
return m_sessionRecoverable;
}
bool ConversationCoordinator::canHandOverSummary() const
{
return m_sessionRecoverable && m_ports.compression->compressionConfigurationIssue().isEmpty()
&& !m_ports.ops->transcriptEmpty();
}
QString ConversationCoordinator::summaryHandoverTooltip() const
{
if (!m_sessionRecoverable)
return {};
if (m_ports.ops->transcriptEmpty())
return tr("There is nothing to summarise yet.");
const QString issue = m_ports.compression->compressionConfigurationIssue();
if (!issue.isEmpty()) {
return tr("A summary cannot be produced because %1. Start a new session without one, or "
"assign the chat feature in the settings.")
.arg(issue);
}
return tr("Summarise this transcript and give it to the new session as context.");
}
void ConversationCoordinator::startFreshSession()
{
if (!m_sessionRecoverable)
return;
m_ports.ops->startFreshAgentSession();
setSessionIssue(QString(), false);
}
void ConversationCoordinator::handOverSummary()
{
if (!canHandOverSummary())
return;
if (m_ports.compression->isCompressionRunning()) {
emit errorSurfaced(tr("A summary is already being prepared"));
return;
}
m_ports.compression->startTranscriptSummary();
}
void ConversationCoordinator::shrinkContext()
{
if (refuseWhileReadOnly())
return;
if (!canShrinkContext()) {
emit errorSurfaced(shrinkContextTooltip());
return;
}
if (m_ports.ops->boundAgentId().isEmpty()) {
m_ports.compression->startCompression();
return;
}
if (m_ports.compression->isCompressionRunning()) {
emit errorSurfaced(tr("A summary is already being prepared"));
return;
}
m_ports.compression->startTranscriptSummary();
}
bool ConversationCoordinator::canShrinkContext() const
{
if (readOnly())
return false;
if (!m_ports.compression->compressionConfigurationIssue().isEmpty())
return false;
if (m_ports.ops->boundAgentId().isEmpty())
return true;
return !m_ports.ops->transcriptEmpty();
}
QString ConversationCoordinator::shrinkContextTooltip() const
{
const QString issue = m_ports.compression->compressionConfigurationIssue();
if (!issue.isEmpty()) {
return tr("Unavailable because %1. Assign the chat feature in the settings.").arg(issue);
}
if (m_ports.ops->boundAgentId().isEmpty())
return tr("Compress chat (create summarized copy using LLM)");
if (m_ports.ops->transcriptEmpty())
return tr("There is nothing to summarise yet.");
return tr("Summarise this conversation and hand it to a fresh agent session as context.");
}
void ConversationCoordinator::summaryProduced(const QString &summary)
{
m_ports.ops->startFreshAgentSession(summary);
setSessionIssue(QString(), false);
}
QString ConversationCoordinator::agentTitle() const
{
return m_agentTitle;
}
void ConversationCoordinator::titleSuggested(const QString &title)
{
if (m_agentTitle == title)
return;
m_agentTitle = title;
emit agentTitleChanged();
}
void ConversationCoordinator::agentSessionUnavailable(const QString &reason)
{
LOG_MESSAGE(QString("Agent session could not be reopened: %1").arg(reason));
setSessionIssue(
tr("The previous session with this agent could not be reopened, so the transcript "
"is read-only. Start a new session to keep working with this agent — it will "
"not have the context above."),
true);
}
void ConversationCoordinator::conversationReset()
{
if (!m_agentTitle.isEmpty()) {
m_agentTitle.clear();
emit agentTitleChanged();
}
setSessionIssue(QString(), false);
}
void ConversationCoordinator::setSessionIssue(const QString &issue, bool recoverable)
{
if (m_sessionIssue == issue && m_sessionRecoverable == recoverable)
return;
m_sessionIssue = issue;
m_sessionRecoverable = recoverable;
emit sessionIssueChanged();
}
} // namespace QodeAssist::Chat
@@ -0,0 +1,100 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <optional>
#include <QObject>
#include <QString>
#include <QStringList>
#include "ConversationPorts.hpp"
namespace QodeAssist::Chat {
class ConversationCoordinator : public QObject
{
Q_OBJECT
public:
struct Ports
{
IConversationPort *ops = nullptr;
IAgentCatalogPort *catalog = nullptr;
ICompressionPort *compression = nullptr;
ISendPort *send = nullptr;
};
explicit ConversationCoordinator(const Ports &ports, QObject *parent = nullptr);
void requestSend(const QString &message, const QStringList &attachments);
bool refuseWhileReadOnly();
bool hasDeferredSend() const;
void chooseAgent(const Acp::AgentDefinition &agent);
void chooseLlm();
void confirmSwitch();
void cancelSwitch();
bool switchPending() const;
void restoreAgentBinding(const Acp::AgentBinding &binding);
Acp::AgentBinding bindingForSave() const;
QString sessionIssue() const;
bool readOnly() const;
bool canStartFreshSession() const;
bool canHandOverSummary() const;
QString summaryHandoverTooltip() const;
void startFreshSession();
void handOverSummary();
void shrinkContext();
bool canShrinkContext() const;
QString shrinkContextTooltip() const;
QString agentTitle() const;
void conversationReset();
public slots:
void titleSuggested(const QString &title);
void agentSessionUnavailable(const QString &reason);
void summaryProduced(const QString &summary);
void compressionSettled();
signals:
void sessionIssueChanged();
void agentTitleChanged();
void boundToAgent(const QodeAssist::Acp::AgentDefinition &agent);
void boundToLlm();
void switchConfirmationNeeded(const QString &targetName);
void switchCancelled();
void errorSurfaced(const QString &message);
private:
struct DeferredSend
{
QString message;
QStringList attachments;
bool active = false;
};
void setSessionIssue(const QString &issue, bool recoverable);
bool deferSendForAutoCompress(const QString &message, const QStringList &attachments);
void bindAgentNow(const Acp::AgentDefinition &agent);
void bindLlmNow();
Ports m_ports;
std::optional<Acp::AgentDefinition> m_pendingAgent;
bool m_pendingLlmSwitch = false;
DeferredSend m_deferredSend;
QString m_agentTitle;
QString m_sessionIssue;
bool m_sessionRecoverable = false;
Acp::AgentBinding m_quarantinedBinding;
};
} // namespace QodeAssist::Chat
+70
View File
@@ -0,0 +1,70 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <optional>
#include <QString>
#include <QStringList>
#include "acp/AgentBinding.hpp"
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Chat {
class IConversationPort
{
public:
virtual ~IConversationPort() = default;
virtual QString boundAgentId() const = 0;
virtual bool conversationStarted() const = 0;
virtual bool transcriptEmpty() const = 0;
virtual Acp::AgentBinding agentBinding() const = 0;
virtual void bindAgent(const Acp::AgentDefinition &agent) = 0;
virtual void bindLlm() = 0;
virtual void clearConversation() = 0;
virtual void resumeAgentSession(const QString &sessionId) = 0;
virtual void startFreshAgentSession() = 0;
virtual void startFreshAgentSession(const QString &handoverSummary) = 0;
virtual void releaseAgentSession() = 0;
};
class IAgentCatalogPort
{
public:
virtual ~IAgentCatalogPort() = default;
virtual std::optional<Acp::AgentDefinition> agentById(const QString &agentId) const = 0;
};
class ICompressionPort
{
public:
virtual ~ICompressionPort() = default;
virtual QString compressionConfigurationIssue() const = 0;
virtual bool isCompressionRunning() const = 0;
virtual void startTranscriptSummary() = 0;
virtual void startCompression() = 0;
};
class ISendPort
{
public:
virtual ~ISendPort() = default;
virtual bool autoCompressEnabled() const = 0;
virtual int autoCompressThreshold() const = 0;
virtual int estimatedNextTokens() const = 0;
virtual bool prepareChatFileForCompression(
const QString &message, const QStringList &attachments)
= 0;
virtual void dispatch(const QString &message, const QStringList &attachments) = 0;
};
} // namespace QodeAssist::Chat
+22 -22
View File
@@ -11,27 +11,27 @@
#include <texteditor/texteditor.h>
#include "Logger.hpp"
#include "context/ChangesManager.h"
#include "context/FileEditManager.hpp"
namespace QodeAssist::Chat {
FileEditController::FileEditController(QObject *parent)
: QObject(parent)
{
auto &changes = Context::ChangesManager::instance();
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
auto &changes = Context::FileEditManager::instance();
connect(&changes, &Context::FileEditManager::fileEditAdded, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &) {
connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &) {
connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditUndone, this, [this](const QString &) {
connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &) {
updateStats();
});
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &) {
connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &) {
updateStats();
});
}
@@ -76,11 +76,11 @@ int FileEditController::rejectedEdits() const
void FileEditController::applyFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
if (Context::FileEditManager::instance().applyFileEdit(editId)) {
emit infoMessage(QString("File edit applied successfully"));
updateStats();
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to apply file edit")
@@ -91,11 +91,11 @@ void FileEditController::applyFileEdit(const QString &editId)
void FileEditController::rejectFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
if (Context::FileEditManager::instance().rejectFileEdit(editId)) {
emit infoMessage(QString("File edit rejected"));
updateStats();
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to reject file edit")
@@ -106,11 +106,11 @@ void FileEditController::rejectFileEdit(const QString &editId)
void FileEditController::undoFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
if (Context::FileEditManager::instance().undoFileEdit(editId)) {
emit infoMessage(QString("File edit undone successfully"));
updateStats();
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to undo file edit")
@@ -122,7 +122,7 @@ void FileEditController::openFileEditInEditor(const QString &editId)
{
LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId));
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
if (edit.editId.isEmpty()) {
emit errorOccurred(QString("File edit not found: %1").arg(editId));
return;
@@ -143,7 +143,7 @@ void FileEditController::openFileEditInEditor(const QString &editId)
QString currentContent = doc->toPlainText();
int position = -1;
if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) {
if (edit.status == Context::FileEditManager::Applied && !edit.newContent.isEmpty()) {
position = currentContent.indexOf(edit.newContent);
} else if (!edit.oldContent.isEmpty()) {
position = currentContent.indexOf(edit.oldContent);
@@ -171,7 +171,7 @@ void FileEditController::applyAllForCurrentMessage()
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
bool success = Context::FileEditManager::instance()
.reapplyAllEditsForRequest(m_currentRequestId, &errorMsg);
if (success) {
@@ -196,7 +196,7 @@ void FileEditController::undoAllForCurrentMessage()
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentRequestId));
QString errorMsg;
bool success = Context::ChangesManager::instance()
bool success = Context::FileEditManager::instance()
.undoAllEditsForRequest(m_currentRequestId, &errorMsg);
if (success) {
@@ -225,7 +225,7 @@ void FileEditController::updateStats()
return;
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
auto edits = Context::FileEditManager::instance().getEditsForRequest(m_currentRequestId);
int total = edits.size();
int applied = 0;
@@ -234,16 +234,16 @@ void FileEditController::updateStats()
for (const auto &edit : edits) {
switch (edit.status) {
case Context::ChangesManager::Applied:
case Context::FileEditManager::Applied:
applied++;
break;
case Context::ChangesManager::Pending:
case Context::FileEditManager::Pending:
pending++;
break;
case Context::ChangesManager::Rejected:
case Context::FileEditManager::Rejected:
rejected++;
break;
case Context::ChangesManager::Archived:
case Context::FileEditManager::Archived:
total--;
break;
}
+7 -7
View File
@@ -4,6 +4,8 @@
#include "FileMentionItem.hpp"
#include "settings/ChatAssistantSettings.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
@@ -11,6 +13,7 @@
#include <coreplugin/editormanager/documentmodel.h>
#include <coreplugin/editormanager/editormanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
@@ -113,8 +116,7 @@ void FileMentionItem::dismiss()
emit dismissed();
}
QVariantMap FileMentionItem::applyCurrentSelection(
const QString &text, int cursorPosition, bool useTools)
QVariantMap FileMentionItem::applyCurrentSelection(const QString &text, int cursorPosition)
{
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) {
dismiss();
@@ -139,8 +141,7 @@ QVariantMap FileMentionItem::applyCurrentSelection(
item.value("absolutePath").toString(),
item.value("relativePath").toString(),
item.value("projectName").toString(),
currentQuery,
useTools);
currentQuery);
if (result.value("mode").toString() == "mention")
replacement = result.value("mentionText").toString();
@@ -158,8 +159,7 @@ QVariantMap FileMentionItem::handleFileSelection(
const QString &absolutePath,
const QString &relativePath,
const QString &projectName,
const QString &currentQuery,
bool useTools)
const QString &currentQuery)
{
QVariantMap result;
const QString fileName = relativePath.section('/', -1);
@@ -172,7 +172,7 @@ QVariantMap FileMentionItem::handleFileSelection(
mentionKey = projPrefix + ":" + fileName;
}
if (useTools) {
if (Settings::chatAssistantSettings().enableChatTools()) {
registerMention(mentionKey, absolutePath);
result["mode"] = QStringLiteral("mention");
result["mentionText"] = "@" + mentionKey + " ";
+2 -4
View File
@@ -36,11 +36,9 @@ public:
const QString &absolutePath,
const QString &relativePath,
const QString &projectName,
const QString &currentQuery,
bool useTools);
const QString &currentQuery);
Q_INVOKABLE QVariantMap applyCurrentSelection(
const QString &text, int cursorPosition, bool useTools);
Q_INVOKABLE QVariantMap applyCurrentSelection(const QString &text, int cursorPosition);
Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath);
Q_INVOKABLE void clearMentions();
+3 -12
View File
@@ -79,12 +79,6 @@ void InputTokenCounter::setAttachments(const QStringList &attachments)
recompute();
}
void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles)
{
m_linkedFiles = linkedFiles;
recompute();
}
void InputTokenCounter::rewireToolsChangedConnection()
{
if (m_toolsChangedConn)
@@ -160,14 +154,11 @@ void InputTokenCounter::recompute()
inputTokens += estimateFileTokens(textPaths);
}
if (!m_linkedFiles.isEmpty()) {
QStringList textPaths;
inputTokens += splitImageEstimate(m_linkedFiles, textPaths);
inputTokens += estimateFileTokens(textPaths);
}
if (m_session) {
for (const Session::MessageRow &row : m_session->rows()) {
if (Session::rowTreatmentFor(Session::RowAudience::TokenCount, row.kind)
== Session::RowTreatment::Omit)
continue;
inputTokens += Context::TokenUtils::estimateTokens(row.content);
inputTokens += 4; // + role
}
-2
View File
@@ -35,7 +35,6 @@ public:
void setMessage(const QString &message);
void setAttachments(const QStringList &attachments);
void setLinkedFiles(const QStringList &linkedFiles);
void recompute();
void recomputeSoon();
@@ -62,7 +61,6 @@ private:
QHash<QString, CachedFileTokens> m_fileTokens;
QStringList m_attachments;
QStringList m_linkedFiles;
int m_messageTokens{0};
int m_inputTokens{0};
int m_lastSentEstimate{0};
+164 -34
View File
@@ -6,14 +6,18 @@
#include <LLMQore/ToolsManager.hpp>
#include <algorithm>
#include <QPromise>
#include "ChatSerializer.hpp"
#include <memory>
#include "ChatFileStore.hpp"
#include "llmcore/ContextData.hpp"
#include "logger/Logger.hpp"
#include "providers/ProvidersManager.hpp"
#include "session/FencedText.hpp"
#include "session/FileEditPayload.hpp"
#include "session/HistoryProjection.hpp"
#include "settings/ChatAssistantSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include "settings/ToolsSettings.hpp"
#include "tools/ReadOriginalHistoryTool.hpp"
@@ -24,8 +28,16 @@ namespace QodeAssist::Chat {
LlmChatBackend::LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent)
: Session::ChatBackend(parent)
, m_promptProvider(promptProvider)
, m_providerResolver([](const QString &name) {
return Providers::ProvidersManager::instance().getProviderByName(name);
})
{}
void LlmChatBackend::setProviderResolver(ProviderResolver resolver)
{
m_providerResolver = std::move(resolver);
}
LlmChatBackend::~LlmChatBackend()
{
cancel();
@@ -42,7 +54,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
}
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
auto *provider = m_providerResolver(providerName);
if (!provider) {
const QString error = tr("No provider found with name: %1").arg(providerName);
@@ -62,7 +74,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
}
LLMCore::ContextData context;
if (request.context)
if (request.context && Settings::chatAssistantSettings().useSystemPrompt())
context.systemPrompt = Session::renderSystemPrompt(*request.context);
context.history = renderHistory(*request.history, provider, promptTemplate);
@@ -73,8 +85,8 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
promptTemplate,
context,
LLMCore::RequestType::Chat,
request.options.useTools,
request.options.useThinking);
Settings::chatAssistantSettings().enableChatTools(),
Settings::chatAssistantSettings().enableThinkingMode());
provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
provider->client()->setTransferTimeout(
@@ -88,10 +100,10 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
m_provider = provider;
m_dropPreToolText = !promptTemplate->supportsToolHistory();
m_requestId
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
const QString requestId = m_ledger.beginTurn(
provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint));
emit sessionEvent(Session::TurnStarted{.turnId = m_requestId});
emit sessionEvent(Session::TurnStarted{.turnId = requestId});
bindToolSessions(provider);
}
@@ -102,7 +114,7 @@ void LlmChatBackend::cancel()
return;
auto *provider = m_provider;
const QString requestId = m_requestId;
const QString requestId = m_ledger.activeTurnId();
releaseRequest();
@@ -114,14 +126,23 @@ void LlmChatBackend::cancel()
void LlmChatBackend::releaseRequest()
{
if (m_provider)
cancelPendingPermissions();
if (m_provider) {
disconnect(m_provider->client(), nullptr, this, nullptr);
if (m_provider->toolsManager())
m_provider->toolsManager()->setExecutionGate({});
}
m_provider = nullptr;
m_requestId.clear();
m_dropPreToolText = false;
}
Session::TurnContextNeeds LlmChatBackend::contextNeeds() const
{
return {Settings::chatAssistantSettings().useSystemPrompt()};
}
void LlmChatBackend::setChatFilePath(const QString &filePath)
{
m_chatFilePath = filePath;
@@ -133,7 +154,7 @@ void LlmChatBackend::clearToolSession(const QString &filePath)
return;
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
auto *provider = m_providerResolver(providerName);
if (!provider || !provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
|| !provider->toolsManager()) {
@@ -209,6 +230,106 @@ void LlmChatBackend::bindToolSessions(Providers::Provider *provider)
provider->toolsManager()->tool("read_original_history"))) {
historyTool->setCurrentSessionId(m_chatFilePath);
}
installExecutionGate(provider);
}
void LlmChatBackend::installExecutionGate(Providers::Provider *provider)
{
provider->toolsManager()->setExecutionGate(
[this](
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &input) {
return gateToolExecution(requestId, toolId, toolName, input);
});
}
QFuture<bool> LlmChatBackend::gateToolExecution(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &input)
{
const auto allow = [] {
QPromise<bool> promise;
promise.start();
promise.addResult(true);
promise.finish();
return promise.future();
};
if (!m_ledger.isActiveTurn(requestId))
return allow();
if (!m_provider || !m_provider->toolsManager())
return allow();
auto *tool = m_provider->toolsManager()->tool(toolName);
if (tool && tool->safety() == ::LLMQore::ToolSafety::ReadOnly)
return allow();
auto promise = std::make_shared<QPromise<bool>>();
promise->start();
const QString permissionId = m_ledger.registerPermission(
[promise](const QString &optionId) {
const bool allowed = optionId == Session::PermissionOptionKind::AllowOnce
|| optionId == Session::PermissionOptionKind::AllowAlways;
promise->addResult(allowed);
promise->finish();
},
[promise] {
promise->addResult(false);
promise->finish();
});
QFuture<bool> decision = promise->future();
const QString title = tool && !tool->displayName().isEmpty()
? tr("Run %1").arg(tool->displayName())
: tr("Run %1").arg(toolName);
emit sessionEvent(
Session::PermissionRequested{
.turnId = requestId,
.requestId = permissionId,
.toolCallId = toolId,
.title = title,
.toolKind = toolName,
.options
= {Session::PermissionOption{"allow_once", tr("Allow"), "allow_once"},
Session::PermissionOption{
"allow_always", tr("Allow for this conversation"), "allow_always"},
Session::PermissionOption{"reject_once", tr("Don't run it"), "reject_once"}}});
Q_UNUSED(input)
return decision;
}
bool LlmChatBackend::respondPermission(const QString &requestId, const QString &optionId)
{
if (!m_ledger.resolvePermission(requestId, optionId))
return false;
emit sessionEvent(
Session::PermissionResolved{
.turnId = m_ledger.activeTurnId(), .requestId = requestId, .optionId = optionId});
return true;
}
void LlmChatBackend::cancelPendingPermissions()
{
const QString turnId = m_ledger.activeTurnId();
const QStringList cancelled = m_ledger.endTurn();
for (const QString &requestId : cancelled) {
emit sessionEvent(
Session::PermissionResolved{
.turnId = turnId, .requestId = requestId, .cancelled = true});
}
}
QVector<LLMCore::Message> LlmChatBackend::renderHistory(
@@ -222,7 +343,10 @@ QVector<LLMCore::Message> LlmChatBackend::renderHistory(
int toolCallMsgIdx = -1;
for (const Session::MessageRow &row : Session::projectToRows(history)) {
if (row.kind == Session::RowKind::Tool) {
const Session::RowTreatment treatment
= Session::rowTreatmentFor(Session::RowAudience::Prompt, row.kind);
if (treatment == Session::RowTreatment::ToolExchange) {
if (!toolHistory || row.toolName.isEmpty())
continue;
@@ -250,29 +374,29 @@ QVector<LLMCore::Message> LlmChatBackend::renderHistory(
toolCallMsgIdx = -1;
if (row.kind == Session::RowKind::FileEdit)
if (treatment == Session::RowTreatment::Omit)
continue;
LLMCore::Message apiMessage;
apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant";
apiMessage.content = row.content;
if (!row.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const Session::AttachmentBlock &attachment : row.attachments) {
const QString fileContent
= ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.storedPath);
const QByteArray fileContent = ChatFileStore::loadRawContentFromStorage(
m_chatFilePath, attachment.storedPath);
if (fileContent.isEmpty())
continue;
const QString decodedContent = QString::fromUtf8(
QByteArray::fromBase64(fileContent.toUtf8()));
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
.arg(attachment.fileName, decodedContent);
apiMessage.content
+= "\n\n"
+ Session::fencedFileBlock(
attachment.fileName, QString::fromUtf8(fileContent));
}
}
apiMessage.isThinking = row.kind == Session::RowKind::Thinking;
apiMessage.isThinking = treatment == Session::RowTreatment::AssistantThinking;
apiMessage.isRedacted = row.redacted;
apiMessage.signature = row.signature;
@@ -296,7 +420,7 @@ QVector<LLMCore::ImageAttachment> LlmChatBackend::loadImagesFromStorage(
for (const Session::ImageBlock &storedImage : storedImages) {
const QString base64Data
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
= ChatFileStore::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;
@@ -315,7 +439,7 @@ QVector<LLMCore::ImageAttachment> LlmChatBackend::loadImagesFromStorage(
void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
{
if (requestId != m_requestId)
if (!m_ledger.isActiveTurn(requestId))
return;
emit sessionEvent(Session::TextDelta{.turnId = requestId, .text = chunk});
@@ -323,7 +447,7 @@ void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fullText)
{
if (requestId != m_requestId)
if (!m_ledger.isActiveTurn(requestId))
return;
LOG_MESSAGE(
@@ -337,7 +461,7 @@ void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fu
void LlmChatBackend::handleFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (requestId != m_requestId || !info.usage)
if (!m_ledger.isActiveTurn(requestId) || !info.usage)
return;
const auto &usage = *info.usage;
@@ -361,7 +485,7 @@ void LlmChatBackend::handleFinalized(
void LlmChatBackend::handleFailed(const QString &requestId, const QString &error)
{
if (requestId != m_requestId)
if (!m_ledger.isActiveTurn(requestId))
return;
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
@@ -374,7 +498,7 @@ void LlmChatBackend::handleFailed(const QString &requestId, const QString &error
void LlmChatBackend::handleThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature)
{
if (requestId != m_requestId)
if (!m_ledger.isActiveTurn(requestId))
return;
emit sessionEvent(
@@ -391,14 +515,15 @@ void LlmChatBackend::handleToolStarted(
const QString &toolName,
const QJsonObject &arguments)
{
if (requestId != m_requestId)
if (!m_ledger.isActiveTurn(requestId))
return;
emit sessionEvent(
Session::ToolCallStarted{
Session::ToolCallUpdated{
.turnId = requestId,
.toolId = toolId,
.name = toolName,
.status = QStringLiteral("in_progress"),
.arguments = arguments,
.dropPrecedingText = m_dropPreToolText});
}
@@ -409,12 +534,17 @@ void LlmChatBackend::handleToolResult(
const QString &toolName,
const QString &toolOutput)
{
if (requestId != m_requestId)
if (!m_ledger.isActiveTurn(requestId))
return;
const bool failed = toolOutput.startsWith(QLatin1String("Error: "));
emit sessionEvent(
Session::ToolCallCompleted{
.turnId = requestId, .toolId = toolId, .name = toolName, .result = toolOutput});
Session::ToolCallUpdated{
.turnId = requestId,
.toolId = toolId,
.name = toolName,
.status = failed ? QStringLiteral("failed") : QStringLiteral("completed"),
.result = toolOutput});
}
} // namespace QodeAssist::Chat
+21 -4
View File
@@ -4,13 +4,16 @@
#pragma once
#include <QHash>
#include <functional>
#include <QFuture>
#include <QString>
#include <LLMQore/BaseClient.hpp>
#include "providers/Provider.hpp"
#include "session/ChatBackend.hpp"
#include "session/TurnLedger.hpp"
#include "templates/IPromptProvider.hpp"
namespace QodeAssist::Chat {
@@ -20,19 +23,32 @@ class LlmChatBackend : public Session::ChatBackend
Q_OBJECT
public:
using ProviderResolver = std::function<Providers::Provider *(const QString &name)>;
explicit LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent = nullptr);
~LlmChatBackend() override;
void setProviderResolver(ProviderResolver resolver);
void sendTurn(const Session::TurnRequest &request) override;
void cancel() override;
bool respondPermission(const QString &requestId, const QString &optionId) override;
Session::TurnContextNeeds contextNeeds() const override;
void setChatFilePath(const QString &filePath);
void clearToolSession(const QString &filePath);
void setChatFilePath(const QString &filePath) override;
void clearToolSession(const QString &filePath) override;
private:
void connectClient(Providers::Provider *provider);
void releaseRequest();
void bindToolSessions(Providers::Provider *provider);
void installExecutionGate(Providers::Provider *provider);
QFuture<bool> gateToolExecution(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &input);
void cancelPendingPermissions();
QVector<LLMCore::Message> renderHistory(
const Session::ConversationHistory &history,
Providers::Provider *provider,
@@ -59,10 +75,11 @@ private:
const QString &toolOutput);
Templates::IPromptProvider *m_promptProvider = nullptr;
ProviderResolver m_providerResolver;
QString m_chatFilePath;
Providers::Provider *m_provider = nullptr;
QString m_requestId;
Session::TurnLedger m_ledger;
bool m_dropPreToolText = false;
};
+15 -23
View File
@@ -4,18 +4,31 @@
#include "TurnContextAdapters.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include <projectexplorer/buildconfiguration.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <projectexplorer/target.h>
#include "ProjectSettings.hpp"
#include "SkillsSettings.hpp"
#include "context/ContextManager.hpp"
#include "context/RulesLoader.hpp"
#include "skills/SkillsManager.hpp"
namespace QodeAssist::Chat {
ProjectExplorer::Project *activeProject()
{
auto currentEditor = Core::EditorManager::currentEditor();
if (currentEditor && currentEditor->document()) {
auto project = ProjectExplorer::ProjectManager::projectForFile(
currentEditor->document()->filePath());
if (project)
return project;
}
return ProjectExplorer::ProjectManager::startupProject();
}
ProjectContextQtCreator::ProjectContextQtCreator(ProjectExplorer::Project *project)
: m_project(project)
{}
@@ -38,14 +51,6 @@ Session::ProjectInfo ProjectContextQtCreator::projectInfo() const
return info;
}
QString ProjectContextQtCreator::projectRules() const
{
if (!m_project)
return {};
return Context::RulesLoader::loadRulesForProject(m_project, Context::RulesContext::Chat);
}
SkillsContextQtCreator::SkillsContextQtCreator(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
: m_skillsManager(skillsManager)
@@ -81,19 +86,6 @@ std::optional<Session::InvokedSkill> SkillsContextQtCreator::findSkill(const QSt
return Session::InvokedSkill{skill->name, skill->body};
}
LinkedFilesQtCreator::LinkedFilesQtCreator(Context::ContextManager *contextManager)
: m_contextManager(contextManager)
{}
QList<Session::LinkedFile> LinkedFilesQtCreator::readFiles(const QList<QString> &paths) const
{
QList<Session::LinkedFile> files;
for (const auto &file : m_contextManager->getContentFiles(paths))
files.append(Session::LinkedFile{file.filename, file.content});
return files;
}
std::unique_ptr<SkillsContextQtCreator> makeSkillsContext(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
{
+2 -16
View File
@@ -12,23 +12,20 @@ namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Context {
class ContextManager;
}
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
ProjectExplorer::Project *activeProject();
class ProjectContextQtCreator : public Session::IProjectContextPort
{
public:
explicit ProjectContextQtCreator(ProjectExplorer::Project *project);
Session::ProjectInfo projectInfo() const override;
QString projectRules() const override;
private:
ProjectExplorer::Project *m_project = nullptr;
@@ -47,17 +44,6 @@ private:
Skills::SkillsManager *m_skillsManager = nullptr;
};
class LinkedFilesQtCreator : public Session::ILinkedFilesPort
{
public:
explicit LinkedFilesQtCreator(Context::ContextManager *contextManager);
QList<Session::LinkedFile> readFiles(const QList<QString> &paths) const override;
private:
Context::ContextManager *m_contextManager = nullptr;
};
std::unique_ptr<SkillsContextQtCreator> makeSkillsContext(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project);
-12
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

@@ -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

+156 -81
View File
@@ -47,23 +47,16 @@ ChatRootView {
color: palette.window
}
SplitDropZone {
DropZone {
anchors.fill: parent
z: 99
onFilesDroppedToAttach: (urlStrings) => {
onFilesDropped: (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 {
@@ -76,7 +69,7 @@ ChatRootView {
anchors.bottomMargin: bottomBar.height
active: root.isCompressing
text: qsTr("Compressing chat…")
text: root.isAgentBound ? qsTr("Preparing the handover summary…") : qsTr("Compressing chat…")
}
ColumnLayout {
@@ -138,19 +131,6 @@ ChatRootView {
relocateTooltip.text: (typeof _chatview !== 'undefined')
? qsTr("Move this chat to an editor tab")
: qsTr("Move this chat to a separate window")
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
@@ -165,18 +145,6 @@ ChatRootView {
root.loadAvailableConfigurations()
}
}
roleSelector {
model: root.availableAgentRoles
displayText: root.currentAgentRole
onActivated: function(index) {
root.applyAgentRole(root.availableAgentRoles[index])
}
popup.onAboutToShow: {
root.loadAvailableAgentRoles()
}
}
}
RowLayout {
@@ -248,6 +216,10 @@ ChatRootView {
return fileEditMessageComponent
} else if (model.roleType === ChatModel.Thinking) {
return thinkingMessageComponent
} else if (model.roleType === ChatModel.Permission) {
return permissionMessageComponent
} else if (model.roleType === ChatModel.Plan) {
return planMessageComponent
} else {
return chatItemComponent
}
@@ -357,7 +329,20 @@ ChatRootView {
ToolBlock {
width: parent.width
toolContent: model.content
toolName: model.toolName || ""
toolResult: model.toolResult || ""
toolKind: model.toolKind || ""
toolStatus: model.toolStatus || ""
toolDetails: model.toolDetails || ({})
}
}
Component {
id: planMessageComponent
PlanBlock {
width: parent.width
planContent: model.content
}
}
@@ -386,6 +371,19 @@ ChatRootView {
}
}
Component {
id: permissionMessageComponent
PermissionBlock {
width: parent.width
permissionContent: model.content
onRespond: function(requestId, optionId) {
root.respondToPermission(requestId, optionId)
}
}
}
Component {
id: thinkingMessageComponent
@@ -405,12 +403,76 @@ ChatRootView {
}
}
Rectangle {
id: agentSessionBanner
Layout.fillWidth: true
Layout.margins: 5
visible: root.agentSessionIssue.length > 0
implicitHeight: bannerLayout.implicitHeight + 16
radius: 4
color: palette.base
border.width: 1
border.color: palette.mid
ColumnLayout {
id: bannerLayout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 8
}
spacing: 6
Text {
Layout.fillWidth: true
text: root.agentSessionIssue
textFormat: Text.PlainText
color: palette.text
font.pixelSize: 12
wrapMode: Text.WordWrap
}
RowLayout {
Layout.fillWidth: true
visible: root.canStartNewAgentSession
spacing: 8
QoAButton {
text: qsTr("Continue with a new session")
onClicked: root.startNewAgentSession()
}
QoAButton {
id: handoverButton
text: qsTr("Continue with a summary")
enabled: root.canHandOverSummary
onClicked: root.startNewAgentSessionWithSummary()
QoAToolTip {
visible: handoverButton.hovered
&& root.summaryHandoverTooltip.length > 0
text: root.summaryHandoverTooltip
delay: 300
}
}
Item { Layout.fillWidth: true }
}
}
}
ScrollView {
id: view
Layout.fillWidth: true
Layout.minimumHeight: 30
Layout.maximumHeight: root.height / 2
enabled: root.agentSessionIssue.length === 0
QQC.TextArea {
id: messageInput
@@ -452,20 +514,7 @@ ChatRootView {
}
}
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()
root.refreshSlashPopup()
}
Keys.onPressed: function(event) {
@@ -561,17 +610,6 @@ ChatRootView {
onRemoveFileFromListByIndex: (index) => root.removeFileFromAttachList(index)
}
AttachedFilesPlace {
id: linkedFilesPlace
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
@@ -593,6 +631,7 @@ ChatRootView {
isCompressing: root.isCompressing
isProcessing: root.isRequestInProgress
agentMode: root.isAgentBound
sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage()
: root.cancelRequest()
sendButton.icon.source: root.isRequestInProgress
@@ -608,14 +647,12 @@ ChatRootView {
? root.lastErrorMessage
: qsTr("Send message to LLM %1").arg(root.sendShortcutText))
compressButton.onClicked: compressConfirmDialog.open()
compressButton.enabled: root.canShrinkContext
compressButton.text: root.isAgentBound ? qsTr("Hand over") : qsTr("Compress")
compressTooltip.text: root.shrinkContextTooltip
cancelCompressButton.onClicked: root.cancelCompression()
syncOpenFiles {
checked: root.isSyncOpenFiles
onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
}
attachFiles.onClicked: root.showAttachFilesDialog()
attachImages.onClicked: root.showAddImageDialog()
linkFiles.onClicked: root.showLinkFilesDialog()
}
}
@@ -656,13 +693,36 @@ ChatRootView {
function applyMentionSelection() {
var result = fileMentionPopup.applyCurrentSelection(
messageInput.text, messageInput.cursorPosition, root.useTools)
messageInput.text, messageInput.cursorPosition)
if (result.text !== undefined) {
messageInput.text = result.text
messageInput.cursorPosition = result.cursorPosition
}
}
onSlashCommandsChanged: {
if (skillCommandPopup.visible)
refreshSlashPopup()
}
function refreshSlashPopup() {
const cursorPos = messageInput.cursorPosition
const textBefore = messageInput.text.substring(0, cursorPos)
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')
&& /^\S*$/.test(skillQuery)) {
skillCommandPopup.updateSearch(skillQuery)
return
}
}
skillCommandPopup.dismiss()
}
function applySkillSelection() {
const name = skillCommandPopup.currentName()
if (name === "")
@@ -688,16 +748,42 @@ ChatRootView {
scrollToBottom()
}
onChatTargetSwitchNeedsNewChat: function(targetName) {
chatTargetSwitchDialog.targetName = targetName
chatTargetSwitchDialog.open()
}
Dialog {
id: compressConfirmDialog
id: chatTargetSwitchDialog
property string targetName: ""
anchors.centerIn: parent
title: qsTr("Compress Chat")
title: qsTr("Start a New Conversation")
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.")
text: qsTr("A conversation stays with the kind it started with, so switching to %1 needs a new one.\n\nClear this chat and switch?").arg(chatTargetSwitchDialog.targetName)
wrapMode: Text.WordWrap
}
onAccepted: root.confirmChatTargetSwitch()
onRejected: root.cancelChatTargetSwitch()
}
Dialog {
id: compressConfirmDialog
anchors.centerIn: parent
title: root.isAgentBound ? qsTr("Hand Over Session") : qsTr("Compress Chat")
modal: true
standardButtons: Dialog.Yes | Dialog.No
Label {
text: root.isAgentBound
? qsTr("Summarise this conversation and continue in a fresh agent session?\n\nThe summary will be generated by the LLM chat configuration and given to the new session as context.")
: 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
}
@@ -840,19 +926,8 @@ ChatRootView {
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 {
@@ -0,0 +1,29 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
.pragma library
function parseMarkerPayload(marker, content) {
if (!content || !content.startsWith(marker))
return null;
try {
const parsed = JSON.parse(content.substring(marker.length));
return (parsed && typeof parsed === "object") ? parsed : null;
} catch (e) {
return null;
}
}
function parseFileEdit(content) {
return parseMarkerPayload("QODEASSIST_FILE_EDIT:", content);
}
function parsePermission(content) {
return parseMarkerPayload("QODEASSIST_PERMISSION:", content);
}
function parsePlan(content) {
return parseMarkerPayload("QODEASSIST_PLAN:", content);
}
@@ -8,6 +8,7 @@ import QtQuick.Layouts
import UIControls
import ChatView
import Qt.labs.platform as Platform
import "BlockPayload.js" as BlockPayload
Rectangle {
id: root
@@ -39,13 +40,11 @@ Rectangle {
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)
@@ -84,23 +83,15 @@ Rectangle {
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: ""
};
}
const parsed = BlockPayload.parseFileEdit(content);
return parsed !== null ? parsed : {
edit_id: "",
file: "",
old_content: "",
new_content: "",
status: "error",
status_message: ""
};
}
function getFileName(path) {
@@ -0,0 +1,208 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply see LICENSE
import QtQuick
import QtQuick.Layouts
import UIControls
import "BlockPayload.js" as BlockPayload
Rectangle {
id: root
property string permissionContent: ""
readonly property var permissionData: parsePermissionData(permissionContent)
readonly property bool isMalformed: permissionData === null
readonly property string requestId: isMalformed ? "" : (permissionData.requestId || "")
readonly property string title: isMalformed
? qsTr("Unreadable permission request")
: (permissionData.title || qsTr("The agent asks for permission"))
readonly property string toolKind: isMalformed ? "" : (permissionData.toolKind || "")
readonly property var options: isMalformed ? [] : (permissionData.options || [])
readonly property string status: isMalformed ? "error" : (permissionData.status || "pending")
readonly property string selectedOptionId: isMalformed ? "" : (permissionData.selectedOptionId || "")
readonly property bool automatic: !isMalformed && permissionData.automatic === true
readonly property bool isPending: status === "pending" && options.length > 0
readonly property bool isCancelled: status === "cancelled"
readonly property bool wasDeclinedUnpresentable: isCancelled && options.length === 0
readonly property var selectedOption: findOption(selectedOptionId)
readonly property bool wasAllowed: selectedOption !== null
&& selectedOption.allows === true
readonly property int borderRadius: 6
readonly property int contentMargin: 10
readonly property int badgeRadius: 3
readonly property int badgePaddingH: 12
readonly property int badgePaddingV: 6
readonly property int titleMaxLines: 4
readonly property int rowSpacing: 8
readonly property color allowedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
readonly property color deniedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8)
readonly property color cancelledColor: Qt.rgba(0.5, 0.5, 0.5, 0.8)
readonly property color statusColor: {
if (isMalformed)
return deniedColor;
if (isPending)
return palette.highlight;
if (isCancelled)
return cancelledColor;
return wasAllowed ? allowedColor : deniedColor;
}
readonly property string statusText: {
if (isMalformed)
return qsTr("UNREADABLE");
if (isPending)
return qsTr("WAITING FOR YOU");
if (wasDeclinedUnpresentable)
return qsTr("DECLINED");
if (isCancelled)
return qsTr("NO LONGER AVAILABLE");
if (automatic)
return wasAllowed ? qsTr("ALLOWED AUTOMATICALLY") : qsTr("DENIED AUTOMATICALLY");
return wasAllowed ? qsTr("ALLOWED") : qsTr("DENIED");
}
readonly property string explanation: {
if (isMalformed)
return qsTr("This permission record could not be read, so it cannot be answered.");
if (wasDeclinedUnpresentable)
return qsTr("The agent did not offer a set of options this chat could present safely, so the request was declined.");
if (isCancelled)
return qsTr("This request ended before it was answered.");
if (isPending)
return "";
if (selectedOption === null)
return "";
return automatic
? qsTr("Answered automatically with \"%1\", because you chose that for this action type for the rest of the conversation.").arg(selectedOption.name)
: qsTr("You answered \"%1\".").arg(selectedOption.name);
}
signal respond(string requestId, string optionId)
implicitHeight: layout.implicitHeight + 2 * contentMargin
radius: borderRadius
color: palette.base
border.width: 1
border.color: statusColor
function parsePermissionData(content) {
return BlockPayload.parsePermission(content);
}
function findOption(optionId) {
if (!optionId)
return null;
for (let i = 0; i < root.options.length; ++i) {
if (root.options[i].id === optionId)
return root.options[i];
}
return null;
}
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: root.contentMargin
}
spacing: root.rowSpacing
RowLayout {
Layout.fillWidth: true
spacing: root.rowSpacing
Rectangle {
Layout.preferredWidth: statusLabel.implicitWidth + root.badgePaddingH
Layout.preferredHeight: statusLabel.implicitHeight + root.badgePaddingV
Layout.alignment: Qt.AlignTop
radius: root.badgeRadius
color: root.statusColor
Text {
id: statusLabel
anchors.centerIn: parent
text: root.statusText
textFormat: Text.PlainText
font.pixelSize: 10
font.bold: true
color: palette.base
}
}
Text {
Layout.fillWidth: true
text: root.title
textFormat: Text.PlainText
font.pixelSize: 13
font.bold: true
color: palette.text
wrapMode: Text.WordWrap
maximumLineCount: root.titleMaxLines
elide: Text.ElideRight
}
}
Text {
Layout.fillWidth: true
visible: root.toolKind.length > 0
text: qsTr("Action type: %1").arg(root.toolKind)
textFormat: Text.PlainText
font.pixelSize: 11
color: palette.placeholderText
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: text.length > 0
text: root.explanation
textFormat: Text.PlainText
font.pixelSize: 11
color: palette.placeholderText
wrapMode: Text.WordWrap
}
Flow {
Layout.fillWidth: true
visible: root.isPending
spacing: root.rowSpacing
Repeater {
model: root.options
delegate: QoAButton {
id: optionButton
required property var modelData
text: optionButton.modelData.name || optionButton.modelData.id
accentColor: optionButton.modelData.allows === true
? root.allowedColor
: root.deniedColor
contentItem: Text {
text: optionButton.text
textFormat: Text.PlainText
color: palette.buttonText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
onClicked: root.respond(root.requestId, optionButton.modelData.id)
}
}
}
}
}
@@ -0,0 +1,151 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply see LICENSE
import QtQuick
import QtQuick.Layouts
import "BlockPayload.js" as BlockPayload
Rectangle {
id: root
property string planContent: ""
property bool expanded: true
readonly property var planData: parsePlanData(planContent)
readonly property var entries: planData === null ? [] : (planData.entries || [])
readonly property int completedCount: countCompleted()
readonly property bool isComplete: entries.length > 0 && completedCount === entries.length
readonly property int borderRadius: 6
readonly property int contentMargin: 10
readonly property int rowSpacing: 6
readonly property color completedColor: Qt.rgba(0.2, 0.8, 0.2, 0.9)
readonly property color activeColor: palette.highlight
readonly property color pendingColor: palette.placeholderText
implicitHeight: layout.implicitHeight + 2 * contentMargin
radius: borderRadius
color: palette.base
border.width: 1
border.color: isComplete ? completedColor : palette.mid
function parsePlanData(content) {
return BlockPayload.parsePlan(content);
}
function countCompleted() {
let done = 0;
for (let i = 0; i < root.entries.length; ++i) {
if (root.entries[i].status === "completed")
++done;
}
return done;
}
function entryColor(status) {
if (status === "completed")
return root.completedColor;
if (status === "in_progress")
return root.activeColor;
return root.pendingColor;
}
function entryMarker(status) {
if (status === "completed")
return "✓";
if (status === "in_progress")
return "▶";
return "○";
}
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: root.contentMargin
}
spacing: root.rowSpacing
MouseArea {
Layout.fillWidth: true
Layout.preferredHeight: header.implicitHeight
cursorShape: Qt.PointingHandCursor
onClicked: root.expanded = !root.expanded
RowLayout {
id: header
anchors.fill: parent
spacing: root.rowSpacing
Text {
text: qsTr("Plan")
textFormat: Text.PlainText
font.pixelSize: 13
font.bold: true
color: palette.text
}
Text {
Layout.fillWidth: true
text: qsTr("%1 of %2 done").arg(root.completedCount).arg(root.entries.length)
textFormat: Text.PlainText
font.pixelSize: 11
color: palette.placeholderText
}
Text {
text: root.expanded ? "▼" : "▶"
font.pixelSize: 10
color: palette.mid
}
}
}
Repeater {
model: root.expanded ? root.entries : []
delegate: RowLayout {
id: entryRow
required property var modelData
Layout.fillWidth: true
spacing: root.rowSpacing
Text {
Layout.alignment: Qt.AlignTop
text: root.entryMarker(entryRow.modelData.status)
textFormat: Text.PlainText
font.pixelSize: 12
color: root.entryColor(entryRow.modelData.status)
}
Text {
Layout.fillWidth: true
text: entryRow.modelData.content || ""
textFormat: Text.PlainText
font.pixelSize: 12
font.strikeout: entryRow.modelData.status === "completed"
color: entryRow.modelData.status === "completed" ? palette.placeholderText
: palette.text
wrapMode: Text.WordWrap
}
Text {
Layout.alignment: Qt.AlignTop
visible: entryRow.modelData.priority === "high"
text: qsTr("high")
textFormat: Text.PlainText
font.pixelSize: 10
color: palette.highlight
}
}
}
}
}
+147 -25
View File
@@ -8,14 +8,52 @@ import Qt.labs.platform as Platform
Rectangle {
id: root
property string toolContent: ""
property string toolName: ""
property string toolResult: ""
property string toolKind: ""
property string toolStatus: ""
property var toolDetails: ({})
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) : ""
readonly property int legacyNewline: toolName === "" ? toolResult.indexOf('\n') : -1
readonly property string headerName: {
if (toolName !== "")
return toolName;
return legacyNewline > 0 ? toolResult.substring(0, legacyNewline) : toolResult;
}
readonly property string resultText: {
if (toolName !== "")
return toolResult;
return legacyNewline > 0 ? toolResult.substring(legacyNewline + 1) : "";
}
readonly property var locations: (toolDetails && toolDetails.locations) ? toolDetails.locations : []
readonly property var diffs: (toolDetails && toolDetails.diffs) ? toolDetails.diffs : []
readonly property bool isRunning: toolStatus === "pending" || toolStatus === "in_progress"
readonly property bool hasFailed: toolStatus === "failed"
readonly property color statusColor: {
if (hasFailed)
return Qt.rgba(0.8, 0.2, 0.2, 0.9);
if (isRunning)
return palette.highlight;
if (toolStatus === "completed")
return Qt.rgba(0.2, 0.8, 0.2, 0.9);
return palette.mid;
}
readonly property string statusMarker: {
if (hasFailed)
return "✗";
if (toolStatus === "completed")
return "✓";
if (isRunning)
return "…";
return "";
}
radius: 6
color: palette.base
@@ -45,10 +83,26 @@ Rectangle {
spacing: 8
Text {
text: qsTr("Tool: %1").arg(root.toolName)
anchors.verticalCenter: parent.verticalCenter
text: root.statusMarker
textFormat: Text.PlainText
visible: text.length > 0
font.pixelSize: 12
color: root.statusColor
}
Text {
id: headerTitle
width: headerRow.width - x - 30
text: root.toolKind.length > 0
? root.toolKind + ": " + root.headerName
: qsTr("Tool: %1").arg(root.headerName)
textFormat: Text.PlainText
font.pixelSize: 13
font.bold: true
color: palette.text
color: root.hasFailed ? root.statusColor : palette.text
elide: Text.ElideRight
}
Text {
@@ -60,8 +114,8 @@ Rectangle {
}
}
Column {
id: contentColumn
Loader {
id: contentLoader
anchors {
left: parent.left
@@ -69,20 +123,88 @@ Rectangle {
top: header.bottom
margins: 10
}
spacing: 8
active: root.expanded
sourceComponent: contentComponent
}
TextEdit {
id: resultText
Component {
id: contentComponent
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
Column {
property alias resultEditor: resultEditorItem
spacing: 8
Repeater {
model: root.locations
delegate: Text {
id: locationEntry
required property var modelData
width: contentLoader.width
text: locationEntry.modelData.line !== undefined
? "→ " + locationEntry.modelData.path + ":" + locationEntry.modelData.line
: "→ " + locationEntry.modelData.path
textFormat: Text.PlainText
color: palette.placeholderText
font.pixelSize: 11
elide: Text.ElideMiddle
}
}
Repeater {
model: root.diffs
delegate: Column {
id: diffEntry
required property var modelData
width: contentLoader.width
spacing: 2
Text {
width: parent.width
text: qsTr("Diff") + ": " + (diffEntry.modelData.path || "")
textFormat: Text.PlainText
color: palette.text
font.pixelSize: 11
font.bold: true
elide: Text.ElideMiddle
}
TextEdit {
width: parent.width
text: (diffEntry.modelData.oldText || "") + "\n" + (diffEntry.modelData.newText || "")
textFormat: TextEdit.PlainText
readOnly: true
selectByMouse: true
color: palette.text
wrapMode: Text.WordWrap
font.family: "monospace"
font.pixelSize: 11
selectionColor: palette.highlight
}
}
}
TextEdit {
id: resultEditorItem
width: parent.width
visible: text.length > 0
text: root.resultText
textFormat: TextEdit.PlainText
readOnly: true
selectByMouse: true
color: palette.text
wrapMode: Text.WordWrap
font.family: "monospace"
font.pixelSize: 11
selectionColor: palette.highlight
}
}
}
@@ -98,14 +220,14 @@ Rectangle {
Platform.MenuItem {
text: qsTr("Copy")
enabled: resultText.selectedText.length > 0
onTriggered: resultText.copy()
enabled: contentLoader.item && contentLoader.item.resultEditor.selectedText.length > 0
onTriggered: contentLoader.item.resultEditor.copy()
}
Platform.MenuItem {
text: qsTr("Select All")
enabled: resultText.text.length > 0
onTriggered: resultText.selectAll()
enabled: contentLoader.item && contentLoader.item.resultEditor.text.length > 0
onTriggered: contentLoader.item.resultEditor.selectAll()
}
Platform.MenuSeparator {}
@@ -140,7 +262,7 @@ Rectangle {
when: root.expanded
PropertyChanges {
target: root
implicitHeight: header.height + contentColumn.height + 20
implicitHeight: header.height + contentLoader.height + 20
}
}
]
+7 -32
View File
@@ -12,15 +12,15 @@ 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 compressTooltip: compressTooltipId
property alias cancelCompressButton: cancelCompressButtonId
property bool isCompressing: false
property bool isProcessing: false
property bool agentMode: false
property alias sendButtonTooltip: sendButtonTooltipId
color: palette.window.hslLightness > 0.5 ?
@@ -72,33 +72,6 @@ Rectangle {
}
}
QoAButton {
id: linkFilesId
icon {
source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
height: 15
width: 8
}
QoAToolTip {
visible: linkFilesId.hovered
delay: 250
text: qsTr("Link file to context")
}
}
CheckBox {
id: syncOpenFilesId
text: qsTr("Sync open files")
QoAToolTip {
visible: syncOpenFilesId.hovered
text: qsTr("Automatically synchronize currently opened files with the model context")
}
}
Item {
Layout.fillWidth: true
}
@@ -119,7 +92,7 @@ Rectangle {
}
Text {
text: qsTr("Compressing...")
text: root.agentMode ? qsTr("Preparing summary...") : qsTr("Compressing...")
anchors.verticalCenter: parent.verticalCenter
color: palette.text
font.pixelSize: 12
@@ -134,7 +107,7 @@ Rectangle {
QoAToolTip {
visible: cancelCompressButtonId.hovered
delay: 250
text: qsTr("Cancel compression")
text: root.agentMode ? qsTr("Cancel the summary") : qsTr("Cancel compression")
}
}
}
@@ -143,6 +116,7 @@ Rectangle {
id: compressButtonId
visible: !root.isCompressing
opacity: enabled ? 1.0 : 0.4
text: qsTr("Compress")
icon {
@@ -152,9 +126,10 @@ Rectangle {
}
QoAToolTip {
id: compressTooltipId
visible: compressButtonId.hovered
delay: 250
text: qsTr("Compress chat (create summarized copy using LLM)")
}
}
+1 -297
View File
@@ -14,18 +14,8 @@ 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
@@ -59,11 +49,6 @@ Popup {
Layout.fillWidth: true
}
QoAButton {
text: qsTr("Refresh")
onClicked: root.refreshRules()
}
QoAButton {
text: qsTr("Close")
onClicked: root.close()
@@ -159,281 +144,6 @@ Popup {
}
}
}
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 {
@@ -448,7 +158,7 @@ Popup {
}
Text {
text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files")
text: qsTr("Final prompt: Base System Prompt + Project Info + Skills")
font.pixelSize: 9
color: palette.mid
wrapMode: Text.WordWrap
@@ -534,10 +244,4 @@ Popup {
active: sectionRoot.expanded
}
}
onOpened: {
if (root.activeRulesCount > 0) {
root.ruleSelected(0)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply see LICENSE
import QtQuick
import QtQuick.Controls
Item {
id: root
signal filesDropped(var urlStrings)
property int filesCount: 0
property bool isDragActive: false
Item {
id: dropOverlay
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 {
anchors.fill: parent
color: root.isDragActive
? 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.isDragActive ? 3 : 2
border.color: root.isDragActive
? 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.isDragActive ? palette.highlightedText : palette.text
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Images & Text Files")
font.pixelSize: 14
color: root.isDragActive ? palette.highlightedText : palette.text
opacity: 0.8
}
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
}
DropArea {
id: globalDropArea
anchors.fill: parent
onEntered: (drag) => {
if (drag.hasUrls) {
root.isDragActive = true
root.filesCount = drag.urls.length
dropOverlay.visible = true
dropOverlay.opacity = 1
}
}
onExited: {
root.isDragActive = false
root.filesCount = 0
dropOverlay.opacity = 0
Qt.callLater(function() {
if (!root.isDragActive) {
dropOverlay.visible = false
}
})
}
onDropped: (drop) => {
root.isDragActive = false
root.filesCount = 0
dropOverlay.opacity = 0
Qt.callLater(function() {
dropOverlay.visible = false
})
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)
root.filesDropped(urlStrings)
}
}
}
@@ -9,7 +9,6 @@ import QtQuick.Layouts
Rectangle {
id: root
// Object exposing Q_INVOKABLE QVariantList searchSkills(query).
property var skillProvider: null
property var searchResults: []
property int currentIndex: 0
@@ -25,7 +24,7 @@ Rectangle {
radius: 4
function updateSearch(query) {
searchResults = skillProvider ? skillProvider.searchSkills(query) : []
searchResults = skillProvider ? skillProvider.searchSlashCommands(query) : []
currentIndex = 0
}
@@ -87,19 +86,55 @@ Rectangle {
anchors.bottomMargin: 4
spacing: 1
Text {
RowLayout {
Layout.fillWidth: true
text: "/" + delegateItem.modelData.name
color: delegateItem.index === root.currentIndex
? palette.highlightedText
: palette.text
font.bold: true
elide: Text.ElideRight
spacing: 6
Text {
Layout.fillWidth: true
text: "/" + delegateItem.modelData.name
textFormat: Text.PlainText
color: delegateItem.index === root.currentIndex
? palette.highlightedText
: palette.text
font.bold: true
elide: Text.ElideRight
}
Rectangle {
visible: sourceBadge.text.length > 0
implicitWidth: Math.min(sourceBadge.implicitWidth + 10, 140)
implicitHeight: sourceBadge.implicitHeight + 2
radius: height / 2
color: delegateItem.index === root.currentIndex
? Qt.rgba(palette.highlightedText.r,
palette.highlightedText.g,
palette.highlightedText.b, 0.2)
: palette.alternateBase
Text {
id: sourceBadge
anchors.fill: parent
anchors.leftMargin: 5
anchors.rightMargin: 5
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
text: delegateItem.modelData.source || ""
textFormat: Text.PlainText
elide: Text.ElideRight
color: delegateItem.index === root.currentIndex
? palette.highlightedText
: palette.mid
font.pixelSize: 10
}
}
}
Text {
Layout.fillWidth: true
text: delegateItem.modelData.description
textFormat: Text.PlainText
color: delegateItem.index === root.currentIndex
? Qt.rgba(palette.highlightedText.r,
palette.highlightedText.g,
@@ -1,276 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply see LICENSE
import QtQuick
import QtQuick.Controls
Item {
id: root
signal filesDroppedToAttach(var urlStrings)
signal filesDroppedToLink(var urlStrings)
property string activeZone: ""
property int filesCount: 0
property bool isDragActive: false
Item {
id: splitDropOverlay
anchors.fill: parent
visible: false
z: 999
opacity: 0
Behavior on opacity {
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
}
Rectangle {
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
topMargin: 30
}
width: fileCountText.width + 40
height: 50
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
radius: 25
visible: root.filesCount > 0
Text {
id: fileCountText
anchors.centerIn: parent
text: qsTr("%n file(s) to drop", "", root.filesCount)
font.pixelSize: 16
font.bold: true
color: palette.highlightedText
}
}
Rectangle {
id: leftZone
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
color: root.activeZone === "left"
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
border.width: root.activeZone === "left" ? 3 : 2
border.color: root.activeZone === "left"
? palette.highlight
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
Column {
anchors.centerIn: parent
spacing: 15
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Attach")
font.pixelSize: 24
font.bold: true
color: root.activeZone === "left" ? palette.highlightedText : palette.text
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Images & Text Files")
font.pixelSize: 14
color: root.activeZone === "left" ? palette.highlightedText : palette.text
opacity: 0.8
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("(for one-time use)")
font.pixelSize: 12
font.italic: true
color: root.activeZone === "left" ? palette.highlightedText : palette.text
opacity: 0.6
}
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
Rectangle {
id: rightZone
anchors {
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
color: root.activeZone === "right"
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
border.width: root.activeZone === "right" ? 3 : 2
border.color: root.activeZone === "right"
? palette.highlight
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
Column {
anchors.centerIn: parent
spacing: 15
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("LINK")
font.pixelSize: 24
font.bold: true
color: root.activeZone === "right" ? palette.highlightedText : palette.text
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Text Files")
font.pixelSize: 14
color: root.activeZone === "right" ? palette.highlightedText : palette.text
opacity: 0.8
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("(added to context)")
font.pixelSize: 12
font.italic: true
color: root.activeZone === "right" ? palette.highlightedText : palette.text
opacity: 0.6
}
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
Rectangle {
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
bottom: parent.bottom
}
width: 2
color: palette.mid
opacity: 0.4
}
MouseArea {
id: leftDropArea
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
hoverEnabled: true
onEntered: {
root.activeZone = "left"
}
}
MouseArea {
id: rightDropArea
anchors {
right: parent.right
top: parent.top
bottom: parent.bottom
}
width: parent.width / 2
hoverEnabled: true
onEntered: {
root.activeZone = "right"
}
}
}
DropArea {
id: globalDropArea
anchors.fill: parent
onEntered: (drag) => {
if (drag.hasUrls) {
root.isDragActive = true
root.filesCount = drag.urls.length
splitDropOverlay.visible = true
splitDropOverlay.opacity = 1
root.activeZone = ""
}
}
onExited: {
root.isDragActive = false
root.filesCount = 0
splitDropOverlay.opacity = 0
Qt.callLater(function() {
if (!root.isDragActive) {
splitDropOverlay.visible = false
root.activeZone = ""
}
})
}
onPositionChanged: (drag) => {
if (drag.hasUrls) {
root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
}
}
onDropped: (drop) => {
const targetZone = root.activeZone
root.isDragActive = false
root.filesCount = 0
splitDropOverlay.opacity = 0
Qt.callLater(function() {
splitDropOverlay.visible = false
root.activeZone = ""
})
if (!drop.hasUrls || drop.urls.length === 0) {
return
}
var urlStrings = []
for (var i = 0; i < drop.urls.length; i++) {
var urlString = drop.urls[i].toString()
if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
urlStrings.push(urlString)
}
}
if (urlStrings.length === 0) {
return
}
drop.accept(Qt.CopyAction)
if (targetZone === "right") {
root.filesDroppedToLink(urlStrings)
} else {
root.filesDroppedToAttach(urlStrings)
}
}
}
}
+1 -75
View File
@@ -23,11 +23,8 @@ Rectangle {
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
property alias relocateTooltip: relocateTooltipId
color: palette.window.hslLightness > 0.5 ?
@@ -147,82 +144,11 @@ Rectangle {
text: qsTr("Switch saved AI configuration")
}
}
QoAComboBox {
id: roleSelector
implicitHeight: 25
model: []
currentIndex: 0
QoAToolTip {
visible: roleSelector.hovered
delay: 250
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
}
QoAToolTip {
visible: toolsButtonId.hovered
delay: 250
text: {
if (!toolsButtonId.enabled) {
return qsTr("Tools are disabled in General Settings")
}
return toolsButtonId.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
}
QoAToolTip {
visible: thinkingModeId.hovered
delay: 250
text: thinkingModeId.enabled
? (thinkingModeId.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
@@ -345,7 +271,7 @@ Rectangle {
QoAToolTip {
visible: contextButtonId.hovered
delay: 250
text: qsTr("View chat context (system prompt, role, rules)")
text: qsTr("View chat context (system prompt)")
}
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <optional>
#include <QString>
#include <QStringList>
#include <LLMQore/AcpTypes.hpp>
#include "acp/AgentDefinition.hpp"
#include "acp/AgentKnowledgeService.hpp"
#include "acp/AgentSpawn.hpp"
#include "acp/ChatPermissionProvider.hpp"
#include "session/ChatBackend.hpp"
#include "session/ContentBlock.hpp"
#include "session/TurnContext.hpp"
#include "session/TurnLedger.hpp"
namespace QodeAssist::Acp {
class AcpChatBackend : public Session::ChatBackend
{
Q_OBJECT
public:
using ClientFactory = std::function<
AgentProcess(const AgentDefinition &agent, const QString &cwd, QObject *parent)>;
using StoredContentLoader
= std::function<QByteArray(const QString &chatFilePath, const QString &storedPath)>;
explicit AcpChatBackend(QObject *parent = nullptr);
void setClientFactory(ClientFactory factory);
void setStoredContentLoader(StoredContentLoader loader);
void setKnowledgeService(AgentKnowledgeService *service);
void bindAgent(const AgentDefinition &agent);
QString boundAgentId() const;
QString boundAgentName() const;
const QList<LLMQore::Acp::AvailableCommand> &availableCommands() const
{
return m_availableCommands;
}
QString acpSessionId() const { return m_sessionId; }
QString bindingSessionId() const
{
return m_sessionId.isEmpty() ? m_resumeSessionId : m_sessionId;
}
void resumeSession(const QString &sessionId);
void startFreshSession();
void setHandoverSummary(const QString &summary);
bool isResumePending() const { return !m_resumeSessionId.isEmpty(); }
void sendTurn(const Session::TurnRequest &request) override;
void cancel() override;
bool respondPermission(const QString &requestId, const QString &optionId) override;
Session::TurnContextNeeds contextNeeds() const override { return {false}; }
void setChatFilePath(const QString &filePath) override;
void clearToolSession(const QString &filePath) override;
signals:
void agentSessionUnavailable(const QString &reason);
void availableCommandsChanged();
private:
struct PendingTurn
{
QList<Session::ContentBlock> userBlocks;
};
void startClient();
void adoptEarlyCommands();
void startSession();
void resumeOrStartSession();
void sendPrompt();
void authenticateAndRetry();
QList<LLMQore::Acp::ContentBlock> buildPrompt() const;
void appendAttachment(
QList<LLMQore::Acp::ContentBlock> &blocks,
const Session::AttachmentBlock &attachment) const;
void appendImage(
QList<LLMQore::Acp::ContentBlock> &blocks, const Session::ImageBlock &image) const;
void connectClient();
void requestPermission(
const QString &requestId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options);
void emitPermissionsCancelled(const QString &turnId, const QStringList &requestIds);
void cancelPendingPermissions(const QString &turnId);
void finishTurn(const QString &turnId);
void failTurn(const QString &error, bool dropProcess);
void releaseClient();
QList<LLMQore::Acp::McpServer> knowledgeServers();
void stopKnowledgeServer();
ClientFactory m_clientFactory;
StoredContentLoader m_storedContentLoader;
AgentKnowledgeService *m_knowledgeService = nullptr;
bool m_knowledgeServerRunning = false;
ChatPermissionProvider *m_permissions = nullptr;
std::optional<AgentDefinition> m_agent;
LLMQore::Acp::AcpClient *m_client = nullptr;
LLMQore::Acp::InitializeResult m_agentInfo;
QString m_sessionId;
QString m_workingDirectory;
QString m_runner;
QString m_chatFilePath;
QString m_resumeSessionId;
QString m_handoverSummary;
QList<LLMQore::Acp::AvailableCommand> m_availableCommands;
QList<LLMQore::Acp::AvailableCommand> m_earlyCommands;
QString m_earlyCommandsSessionId;
QString m_earlyTitle;
QString m_earlyTitleSessionId;
Session::TurnLedger m_ledger;
int m_clientGeneration = 0;
bool m_establishingSession = false;
PendingTurn m_pendingTurn;
QStringList m_stderr;
int m_turnCounter = 0;
bool m_authenticated = false;
};
} // namespace QodeAssist::Acp
+88
View File
@@ -0,0 +1,88 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentBinding.hpp"
#include <QCoreApplication>
namespace QodeAssist::Acp {
namespace {
bool isUsableId(const QString &id)
{
if (id.size() > maxAgentBindingIdLength)
return false;
for (const QChar c : id) {
if (c.category() == QChar::Other_Control || c.category() == QChar::Other_Format)
return false;
}
return true;
}
} // namespace
QString AgentBinding::displayId() const
{
return agentId.size() <= maxAgentBindingIdLength
? agentId
: agentId.first(maxAgentBindingIdLength - 1) + QChar(0x2026);
}
QJsonObject AgentBinding::toJson() const
{
QJsonObject json;
json["agentId"] = agentId;
if (!sessionId.isEmpty())
json["sessionId"] = sessionId;
return json;
}
AgentBinding AgentBinding::fromJson(const QJsonValue &value, QString *error)
{
const auto fail = [error](const QString &reason) {
if (error)
*error = reason;
return AgentBinding{};
};
if (value.isUndefined() || value.isNull())
return {};
if (!value.isObject()) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent binding is not an object"));
}
const QJsonObject json = value.toObject();
if (!json.value("agentId").isString() && json.contains("agentId")) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent id is not a string"));
}
if (!json.value("sessionId").isString() && json.contains("sessionId")) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent session id is not a string"));
}
AgentBinding binding{json["agentId"].toString(), json["sessionId"].toString()};
if (binding.agentId.isEmpty() && !binding.sessionId.isEmpty()) {
return fail(
QCoreApplication::translate(
"QodeAssist", "the agent binding names a session but no agent"));
}
if (!isUsableId(binding.agentId) || !isUsableId(binding.sessionId)) {
return fail(
QCoreApplication::translate("QodeAssist", "the agent binding holds an unusable id"));
}
return binding;
}
} // namespace QodeAssist::Acp
+30
View File
@@ -0,0 +1,30 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QJsonValue>
#include <QString>
namespace QodeAssist::Acp {
inline constexpr qsizetype maxAgentBindingIdLength = 256;
struct AgentBinding
{
QString agentId;
QString sessionId;
bool isEmpty() const { return agentId.isEmpty(); }
QString displayId() const;
bool operator==(const AgentBinding &other) const = default;
QJsonObject toJson() const;
static AgentBinding fromJson(const QJsonValue &value, QString *error = nullptr);
};
} // namespace QodeAssist::Acp
+65
View File
@@ -0,0 +1,65 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentCatalog.hpp"
#include <algorithm>
#include <QHash>
namespace QodeAssist::Acp {
namespace {
constexpr std::array<AgentSource, agentSourceCount> mergeOrderLowestPriorityFirst{
AgentSource::BundledSnapshot, AgentSource::LiveRegistry, AgentSource::UserFile};
} // namespace
void AgentCatalog::setLayer(AgentSource source, const QList<AgentDefinition> &agents)
{
m_layers[static_cast<int>(source)] = agents;
rebuild();
}
QList<AgentDefinition> AgentCatalog::launchableAgents() const
{
QList<AgentDefinition> result;
for (const AgentDefinition &agent : m_merged) {
if (agent.isLaunchable())
result.append(agent);
}
return result;
}
std::optional<AgentDefinition> AgentCatalog::agent(const QString &id) const
{
for (const AgentDefinition &agent : m_merged) {
if (agent.id == id)
return agent;
}
return std::nullopt;
}
void AgentCatalog::rebuild()
{
QHash<QString, AgentDefinition> byId;
for (AgentSource source : mergeOrderLowestPriorityFirst) {
for (const AgentDefinition &agent : std::as_const(m_layers[static_cast<int>(source)]))
byId.insert(agent.id, agent);
}
m_merged = byId.values();
std::sort(
m_merged.begin(),
m_merged.end(),
[](const AgentDefinition &left, const AgentDefinition &right) {
const int byName = QString::compare(left.name, right.name, Qt::CaseInsensitive);
if (byName != 0)
return byName < 0;
return left.id < right.id;
});
}
} // namespace QodeAssist::Acp
+35
View File
@@ -0,0 +1,35 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <array>
#include <optional>
#include <QList>
#include <QString>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
class AgentCatalog
{
public:
void setLayer(AgentSource source, const QList<AgentDefinition> &agents);
QList<AgentDefinition> agents() const { return m_merged; }
QList<AgentDefinition> launchableAgents() const;
std::optional<AgentDefinition> agent(const QString &id) const;
int size() const { return m_merged.size(); }
private:
void rebuild();
std::array<QList<AgentDefinition>, agentSourceCount> m_layers;
QList<AgentDefinition> m_merged;
};
} // namespace QodeAssist::Acp
+165
View File
@@ -0,0 +1,165 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentCatalogStore.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <coreplugin/icore.h>
#include <logger/Logger.hpp>
#include "acp/AgentRegistryParser.hpp"
namespace QodeAssist::Acp {
namespace {
QByteArray readFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return {};
return file.readAll();
}
} // namespace
AgentCatalogStore::AgentCatalogStore(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
{}
QString AgentCatalogStore::userAgentsDirectory()
{
const QString path = QStringLiteral("%1/qodeassist/agents")
.arg(Core::ICore::userResourcePath().toFSPathString());
QDir().mkpath(path);
return path;
}
QString AgentCatalogStore::registryCachePath()
{
const QString directory
= QStringLiteral("%1/qodeassist").arg(Core::ICore::userResourcePath().toFSPathString());
QDir().mkpath(directory);
return directory + QStringLiteral("/acp-registry-cache.json");
}
QUrl AgentCatalogStore::registryUrl()
{
return QUrl(
QStringLiteral("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"));
}
QString AgentCatalogStore::bundledSnapshotPath()
{
return QStringLiteral(":/resources/agents/acp-registry-snapshot.json");
}
bool AgentCatalogStore::hasCachedRegistry() const
{
return QFileInfo::exists(registryCachePath());
}
void AgentCatalogStore::reload()
{
m_warnings.clear();
loadBundledSnapshot();
loadRegistryCache();
loadUserFiles();
emit catalogChanged();
}
void AgentCatalogStore::loadBundledSnapshot()
{
const AgentParseResult result = AgentRegistryParser::parse(
readFile(bundledSnapshotPath()),
AgentSource::BundledSnapshot,
QStringLiteral("bundled snapshot"));
m_warnings.append(result.warnings);
m_catalog.setLayer(AgentSource::BundledSnapshot, result.agents);
}
void AgentCatalogStore::loadRegistryCache()
{
const QString path = registryCachePath();
if (!QFileInfo::exists(path)) {
m_catalog.setLayer(AgentSource::LiveRegistry, {});
return;
}
const AgentParseResult result = AgentRegistryParser::parse(
readFile(path), AgentSource::LiveRegistry, QStringLiteral("registry cache"));
m_warnings.append(result.warnings);
m_catalog.setLayer(AgentSource::LiveRegistry, result.agents);
}
void AgentCatalogStore::loadUserFiles()
{
const QDir directory(userAgentsDirectory());
const QStringList files
= directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name);
QList<AgentDefinition> agents;
for (const QString &file : files) {
const QString path = directory.filePath(file);
const AgentParseResult result
= AgentRegistryParser::parse(readFile(path), AgentSource::UserFile, file);
m_warnings.append(result.warnings);
agents.append(result.agents);
}
m_catalog.setLayer(AgentSource::UserFile, agents);
}
void AgentCatalogStore::refreshFromRegistry()
{
if (m_reply)
return;
m_reply = m_network->get(QNetworkRequest(registryUrl()));
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply *reply = m_reply;
m_reply = nullptr;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit refreshFinished(false, reply->errorString());
return;
}
const QByteArray payload = reply->readAll();
const AgentParseResult result = AgentRegistryParser::parse(
payload, AgentSource::LiveRegistry, QStringLiteral("registry"));
if (result.agents.isEmpty()) {
emit refreshFinished(false, tr("The registry response contained no agents."));
return;
}
QSaveFile cache(registryCachePath());
if (!cache.open(QIODevice::WriteOnly) || cache.write(payload) != payload.size()
|| !cache.commit()) {
emit refreshFinished(
false, tr("Cannot write the registry cache: %1").arg(cache.errorString()));
return;
}
LOG_MESSAGE(QString("ACP registry refreshed: %1 agents").arg(result.agents.size()));
reload();
emit refreshFinished(true, {});
});
}
} // namespace QodeAssist::Acp
+57
View File
@@ -0,0 +1,57 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QUrl>
#include "acp/AgentCatalog.hpp"
QT_BEGIN_NAMESPACE
class QNetworkAccessManager;
class QNetworkReply;
QT_END_NAMESPACE
namespace QodeAssist::Acp {
class AgentCatalogStore : public QObject
{
Q_OBJECT
public:
explicit AgentCatalogStore(QObject *parent = nullptr);
static QString userAgentsDirectory();
static QString registryCachePath();
static QUrl registryUrl();
static QString bundledSnapshotPath();
const AgentCatalog &catalog() const { return m_catalog; }
QStringList warnings() const { return m_warnings; }
bool hasCachedRegistry() const;
bool isRefreshing() const { return m_reply != nullptr; }
void reload();
void refreshFromRegistry();
signals:
void catalogChanged();
void refreshFinished(bool ok, const QString &error);
private:
void loadBundledSnapshot();
void loadRegistryCache();
void loadUserFiles();
QNetworkAccessManager *m_network = nullptr;
QNetworkReply *m_reply = nullptr;
AgentCatalog m_catalog;
QStringList m_warnings;
};
} // namespace QodeAssist::Acp
+94
View File
@@ -0,0 +1,94 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentDefinition.hpp"
#include <QSysInfo>
namespace QodeAssist::Acp {
bool AgentDefinition::isLaunchable() const
{
switch (distribution.kind) {
case AgentDistributionKind::Npx:
case AgentDistributionKind::Uvx:
return !distribution.package.isEmpty();
case AgentDistributionKind::Command:
return !distribution.command.isEmpty();
case AgentDistributionKind::Binary:
case AgentDistributionKind::Unknown:
return false;
}
return false;
}
QString agentSourceName(AgentSource source)
{
switch (source) {
case AgentSource::UserFile:
return QStringLiteral("user file");
case AgentSource::LiveRegistry:
return QStringLiteral("registry");
case AgentSource::BundledSnapshot:
return QStringLiteral("bundled");
}
return {};
}
QString agentDistributionName(AgentDistributionKind kind)
{
switch (kind) {
case AgentDistributionKind::Npx:
return QStringLiteral("npx");
case AgentDistributionKind::Uvx:
return QStringLiteral("uvx");
case AgentDistributionKind::Binary:
return QStringLiteral("binary");
case AgentDistributionKind::Command:
return QStringLiteral("command");
case AgentDistributionKind::Unknown:
return QStringLiteral("unknown");
}
return {};
}
QString currentBinaryPlatform()
{
const QString kernel = QSysInfo::kernelType();
QString os;
if (kernel == QLatin1String("darwin"))
os = QStringLiteral("darwin");
else if (kernel == QLatin1String("linux"))
os = QStringLiteral("linux");
else if (kernel == QLatin1String("winnt"))
os = QStringLiteral("windows");
else
return {};
const QString cpu = QSysInfo::currentCpuArchitecture();
QString arch;
if (cpu == QLatin1String("arm64") || cpu == QLatin1String("aarch64"))
arch = QStringLiteral("aarch64");
else if (cpu == QLatin1String("x86_64"))
arch = QStringLiteral("x86_64");
else
return {};
return os + QLatin1Char('-') + arch;
}
const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent)
{
const QString platform = currentBinaryPlatform();
if (platform.isEmpty())
return nullptr;
for (const AgentBinaryTarget &target : agent.distribution.binaryTargets) {
if (target.platform == platform)
return &target;
}
return nullptr;
}
} // namespace QodeAssist::Acp
+69
View File
@@ -0,0 +1,69 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
namespace QodeAssist::Acp {
enum class AgentSource { BundledSnapshot, LiveRegistry, UserFile };
inline constexpr int agentSourceCount = 3;
enum class AgentDistributionKind { Unknown, Npx, Uvx, Binary, Command };
struct AgentEnvVariable
{
QString name;
QString value;
};
struct AgentBinaryTarget
{
QString platform;
QString archive;
QString sha256;
QString cmd;
QStringList args;
QList<AgentEnvVariable> env;
};
struct AgentDistribution
{
AgentDistributionKind kind = AgentDistributionKind::Unknown;
QString package;
QString command;
QStringList args;
QList<AgentEnvVariable> env;
QList<AgentBinaryTarget> binaryTargets;
};
struct AgentDefinition
{
QString id;
QString name;
QString version;
QString description;
QString icon;
QString repository;
QString website;
QString license;
QStringList authors;
AgentDistribution distribution;
AgentSource source = AgentSource::BundledSnapshot;
QString origin;
bool isLaunchable() const;
};
QString agentSourceName(AgentSource source);
QString agentDistributionName(AgentDistributionKind kind);
QString currentBinaryPlatform();
const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent);
} // namespace QodeAssist::Acp
+21
View File
@@ -0,0 +1,21 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
namespace QodeAssist::Acp {
class AgentKnowledgeService
{
public:
virtual ~AgentKnowledgeService() = default;
virtual QString start() = 0;
virtual void stop() = 0;
virtual QString serverName() const = 0;
};
} // namespace QodeAssist::Acp
+185
View File
@@ -0,0 +1,185 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentLaunch.hpp"
#include <QDir>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QStandardPaths>
namespace QodeAssist::Acp {
namespace {
QList<LLMQore::Acp::EnvVariable> toLlmqoreEnv(const QList<AgentEnvVariable> &env)
{
QList<LLMQore::Acp::EnvVariable> result;
result.reserve(env.size());
for (const AgentEnvVariable &variable : env)
result.append({variable.name, variable.value});
return result;
}
void applyRunner(
LLMQore::Acp::AcpAgentConfig &config,
const QString &runner,
const QStringList &runnerFlags,
const AgentDistribution &distribution)
{
config.command = runner;
config.args = runnerFlags;
config.args.append(distribution.package);
config.args.append(distribution.args);
}
QProcessEnvironment harvestLoginShellEnvironment()
{
QProcessEnvironment result;
#ifndef Q_OS_WIN
static const QRegularExpression assignment(QStringLiteral("^([A-Za-z_][A-Za-z0-9_]*)=(.*)$"));
const QString shell = qEnvironmentVariable("SHELL", QStringLiteral("/bin/sh"));
QProcess process;
process.setProcessChannelMode(QProcess::SeparateChannels);
process.start(
shell,
{QStringLiteral("-l"), QStringLiteral("-i"), QStringLiteral("-c"), QStringLiteral("env")});
if (!process.waitForFinished(5000)) {
process.kill();
process.waitForFinished(1000);
return result;
}
const QString output = QString::fromUtf8(process.readAllStandardOutput());
const QList<QString> lines = output.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
for (const QString &line : lines) {
const QRegularExpressionMatch match = assignment.match(line);
if (match.hasMatch())
result.insert(match.captured(1), match.captured(2));
}
#endif
return result;
}
const QProcessEnvironment &loginShellEnvironment()
{
static const QProcessEnvironment environment = harvestLoginShellEnvironment();
return environment;
}
} // namespace
std::optional<LLMQore::Acp::AcpAgentConfig> agentLaunchConfig(
const AgentDefinition &agent, const QString &workingDirectory)
{
if (!agent.isLaunchable())
return std::nullopt;
LLMQore::Acp::AcpAgentConfig config;
config.cwd = workingDirectory;
config.env = toLlmqoreEnv(agent.distribution.env);
switch (agent.distribution.kind) {
case AgentDistributionKind::Npx:
applyRunner(
config, QStringLiteral("npx"), QStringList{QStringLiteral("-y")}, agent.distribution);
break;
case AgentDistributionKind::Uvx:
applyRunner(config, QStringLiteral("uvx"), {}, agent.distribution);
break;
case AgentDistributionKind::Command:
config.command = agent.distribution.command;
config.args = agent.distribution.args;
break;
case AgentDistributionKind::Binary:
case AgentDistributionKind::Unknown:
return std::nullopt;
}
return config;
}
QStringList splitSearchPaths(const QString &value)
{
QStringList directories;
const QList<QString> entries = value.split(QDir::listSeparator(), Qt::SkipEmptyParts);
for (const QString &entry : entries) {
const QString trimmed = entry.trimmed();
if (!trimmed.isEmpty())
directories.append(trimmed);
}
return directories;
}
void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories)
{
if (extraDirectories.isEmpty())
return;
const bool isBareName = !config.command.isEmpty() && !config.command.contains(QLatin1Char('/'))
&& !config.command.contains(QLatin1Char('\\'));
if (isBareName) {
const QString resolved = QStandardPaths::findExecutable(config.command, extraDirectories);
if (!resolved.isEmpty())
config.command = resolved;
}
const QChar separator = QDir::listSeparator();
const QString inherited = QProcessEnvironment::systemEnvironment().value(QStringLiteral("PATH"));
const QString merged = inherited.isEmpty()
? extraDirectories.join(separator)
: extraDirectories.join(separator) + separator + inherited;
config.env.prepend({QStringLiteral("PATH"), merged});
}
QStringList splitVariableNames(const QString &value)
{
static const QRegularExpression separators(QStringLiteral("[,;\\s]+"));
QStringList names;
const QList<QString> entries = value.split(separators, Qt::SkipEmptyParts);
for (const QString &entry : entries) {
const QString trimmed = entry.trimmed();
if (!trimmed.isEmpty())
names.append(trimmed);
}
return names;
}
void applyForwardedEnvironment(LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames)
{
if (variableNames.isEmpty())
return;
const QProcessEnvironment inherited = QProcessEnvironment::systemEnvironment();
QList<LLMQore::Acp::EnvVariable> forwarded;
QStringList missing;
for (const QString &name : variableNames) {
if (inherited.contains(name))
forwarded.append({name, inherited.value(name)});
else
missing.append(name);
}
if (!missing.isEmpty()) {
const QProcessEnvironment &shell = loginShellEnvironment();
for (const QString &name : std::as_const(missing)) {
if (shell.contains(name))
forwarded.append({name, shell.value(name)});
}
}
for (auto it = forwarded.crbegin(); it != forwarded.crend(); ++it)
config.env.prepend(*it);
}
} // namespace QodeAssist::Acp
+29
View File
@@ -0,0 +1,29 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <optional>
#include <QString>
#include <LLMQore/AcpAgentConfig.hpp>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
std::optional<LLMQore::Acp::AcpAgentConfig> agentLaunchConfig(
const AgentDefinition &agent, const QString &workingDirectory);
QStringList splitSearchPaths(const QString &value);
void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories);
QStringList splitVariableNames(const QString &value);
void applyForwardedEnvironment(
LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames);
} // namespace QodeAssist::Acp
+223
View File
@@ -0,0 +1,223 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentRegistryParser.hpp"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
namespace QodeAssist::Acp {
namespace {
QStringList readStringList(const QJsonValue &value)
{
QStringList result;
const QJsonArray array = value.toArray();
for (const QJsonValue &entry : array) {
if (entry.isString())
result.append(entry.toString());
}
return result;
}
QList<AgentEnvVariable> readEnv(const QJsonValue &value)
{
QList<AgentEnvVariable> result;
if (value.isObject()) {
const QJsonObject object = value.toObject();
for (auto it = object.constBegin(); it != object.constEnd(); ++it)
result.append({it.key(), it.value().toString()});
return result;
}
const QJsonArray array = value.toArray();
for (const QJsonValue &entry : array) {
const QJsonObject object = entry.toObject();
const QString name = object.value(QLatin1String("name")).toString();
if (!name.isEmpty())
result.append({name, object.value(QLatin1String("value")).toString()});
}
return result;
}
AgentDistribution readRunnerDistribution(const QJsonObject &object, AgentDistributionKind kind)
{
AgentDistribution distribution;
distribution.kind = kind;
distribution.package = object.value(QLatin1String("package")).toString();
distribution.args = readStringList(object.value(QLatin1String("args")));
distribution.env = readEnv(object.value(QLatin1String("env")));
return distribution;
}
AgentDistribution readBinaryDistribution(const QJsonObject &object)
{
AgentDistribution distribution;
distribution.kind = AgentDistributionKind::Binary;
for (auto it = object.constBegin(); it != object.constEnd(); ++it) {
const QJsonObject entry = it.value().toObject();
AgentBinaryTarget target;
target.platform = it.key();
target.archive = entry.value(QLatin1String("archive")).toString();
target.sha256 = entry.value(QLatin1String("sha256")).toString();
target.cmd = entry.value(QLatin1String("cmd")).toString();
target.args = readStringList(entry.value(QLatin1String("args")));
target.env = readEnv(entry.value(QLatin1String("env")));
distribution.binaryTargets.append(target);
}
return distribution;
}
AgentDistribution readCommandDistribution(const QJsonObject &object)
{
AgentDistribution distribution;
distribution.kind = AgentDistributionKind::Command;
distribution.command = object.value(QLatin1String("cmd")).toString();
if (distribution.command.isEmpty())
distribution.command = object.value(QLatin1String("command")).toString();
distribution.args = readStringList(object.value(QLatin1String("args")));
distribution.env = readEnv(object.value(QLatin1String("env")));
return distribution;
}
QString distributionProblem(const AgentDistribution &distribution)
{
switch (distribution.kind) {
case AgentDistributionKind::Unknown:
return QStringLiteral("has no supported distribution");
case AgentDistributionKind::Npx:
case AgentDistributionKind::Uvx:
if (distribution.package.isEmpty())
return QStringLiteral("has a %1 distribution without a package")
.arg(agentDistributionName(distribution.kind));
break;
case AgentDistributionKind::Command:
if (distribution.command.isEmpty())
return QStringLiteral("has a command distribution without a cmd");
break;
case AgentDistributionKind::Binary:
if (distribution.binaryTargets.isEmpty())
return QStringLiteral("has a binary distribution without platform targets");
break;
}
return {};
}
AgentDistribution readDistribution(const QJsonObject &object)
{
if (object.contains(QLatin1String("npx")))
return readRunnerDistribution(
object.value(QLatin1String("npx")).toObject(), AgentDistributionKind::Npx);
if (object.contains(QLatin1String("uvx")))
return readRunnerDistribution(
object.value(QLatin1String("uvx")).toObject(), AgentDistributionKind::Uvx);
if (object.contains(QLatin1String("command")))
return readCommandDistribution(object.value(QLatin1String("command")).toObject());
if (object.contains(QLatin1String("binary")))
return readBinaryDistribution(object.value(QLatin1String("binary")).toObject());
return {};
}
} // namespace
namespace AgentRegistryParser {
AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin)
{
AgentParseResult result;
QJsonArray entries;
if (root.isArray()) {
entries = root.toArray();
} else if (root.isObject()) {
const QJsonObject object = root.toObject();
if (object.contains(QLatin1String("agents"))) {
const QJsonValue agents = object.value(QLatin1String("agents"));
if (!agents.isArray()) {
result.warnings.append(QStringLiteral("%1: 'agents' is not an array").arg(origin));
return result;
}
entries = agents.toArray();
} else if (object.contains(QLatin1String("id")))
entries.append(root);
else
result.warnings.append(QStringLiteral("%1: no agents found").arg(origin));
} else {
result.warnings.append(QStringLiteral("%1: not a JSON object or array").arg(origin));
return result;
}
int index = 0;
for (const QJsonValue &entry : std::as_const(entries)) {
const int position = index++;
if (!entry.isObject()) {
result.warnings.append(
QStringLiteral("%1: entry %2 is not an object").arg(origin).arg(position));
continue;
}
const QJsonObject object = entry.toObject();
AgentDefinition agent;
agent.id = object.value(QLatin1String("id")).toString();
if (agent.id.isEmpty()) {
result.warnings.append(
QStringLiteral("%1: entry %2 has no id").arg(origin).arg(position));
continue;
}
agent.name = object.value(QLatin1String("name")).toString();
if (agent.name.isEmpty())
agent.name = agent.id;
agent.version = object.value(QLatin1String("version")).toString();
agent.description = object.value(QLatin1String("description")).toString();
agent.icon = object.value(QLatin1String("icon")).toString();
agent.repository = object.value(QLatin1String("repository")).toString();
agent.website = object.value(QLatin1String("website")).toString();
agent.license = object.value(QLatin1String("license")).toString();
agent.authors = readStringList(object.value(QLatin1String("authors")));
agent.distribution = readDistribution(
object.value(QLatin1String("distribution")).toObject());
agent.source = source;
agent.origin = origin;
const QString problem = distributionProblem(agent.distribution);
if (!problem.isEmpty()) {
result.warnings.append(
QStringLiteral("%1: agent '%2' %3").arg(origin, agent.id, problem));
}
result.agents.append(agent);
}
return result;
}
AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin)
{
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(json, &error);
if (document.isNull()) {
AgentParseResult result;
result.warnings.append(QStringLiteral("%1: %2").arg(origin, error.errorString()));
return result;
}
if (document.isArray())
return parse(QJsonValue(document.array()), source, origin);
return parse(QJsonValue(document.object()), source, origin);
}
} // namespace AgentRegistryParser
} // namespace QodeAssist::Acp
+30
View File
@@ -0,0 +1,30 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QByteArray>
#include <QJsonValue>
#include <QList>
#include <QString>
#include <QStringList>
#include "acp/AgentDefinition.hpp"
namespace QodeAssist::Acp {
struct AgentParseResult
{
QList<AgentDefinition> agents;
QStringList warnings;
};
namespace AgentRegistryParser {
AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin);
AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin);
} // namespace AgentRegistryParser
} // namespace QodeAssist::Acp
+76
View File
@@ -0,0 +1,76 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentSpawn.hpp"
#include <QCoreApplication>
#include <QDir>
#include <extensionsystem/pluginmanager.h>
#include <extensionsystem/pluginspec.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include <LLMQore/AcpClient.hpp>
#include "acp/AgentLaunch.hpp"
#include "settings/AgentsSettings.hpp"
namespace QodeAssist::Acp {
namespace {
LLMQore::Acp::Implementation clientIdentity()
{
QString version;
for (const ExtensionSystem::PluginSpec *spec : ExtensionSystem::PluginManager::plugins()) {
if (spec->name() == QLatin1String("QodeAssist")) {
version = spec->version();
break;
}
}
return {QStringLiteral("QodeAssist"), version, QStringLiteral("QodeAssist")};
}
} // namespace
QString agentWorkingDirectory()
{
if (const ProjectExplorer::Project *project = ProjectExplorer::ProjectManager::startupProject())
return project->projectDirectory().path();
return QDir::homePath();
}
AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent)
{
auto config = agentLaunchConfig(agent, cwd);
if (!config)
return {};
auto &settings = Settings::agentsSettings();
applyExtraSearchPaths(*config, splitSearchPaths(settings.agentExtraPaths.volatileValue()));
applyForwardedEnvironment(
*config, splitVariableNames(settings.agentForwardedVariables.volatileValue()));
auto *transport = config->createTransport(nullptr);
auto *client = new LLMQore::Acp::AcpClient(transport, clientIdentity(), parent);
transport->setParent(client);
return {client, config->command};
}
QString runnerHint(const QString &command)
{
if (command.endsWith(QLatin1String("npx"))) {
return QCoreApplication::translate(
"QtC::QodeAssist", "Make sure Node.js is installed and 'npx' is on PATH.");
}
if (command.endsWith(QLatin1String("uvx"))) {
return QCoreApplication::translate(
"QtC::QodeAssist", "Make sure uv is installed and 'uvx' is on PATH.");
}
return {};
}
} // namespace QodeAssist::Acp
+30
View File
@@ -0,0 +1,30 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include "acp/AgentDefinition.hpp"
namespace LLMQore::Acp {
class AcpClient;
}
namespace QodeAssist::Acp {
struct AgentProcess
{
LLMQore::Acp::AcpClient *client = nullptr;
QString command;
};
QString agentWorkingDirectory();
AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent);
QString runnerHint(const QString &command);
} // namespace QodeAssist::Acp
+180
View File
@@ -0,0 +1,180 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentTester.hpp"
#include <chrono>
#include <LLMQore/AcpClient.hpp>
#include <LLMQore/RpcStdioTransport.hpp>
#include "acp/AgentSpawn.hpp"
namespace QodeAssist::Acp {
namespace {
constexpr int maxStderrLines = 20;
QString capabilityList(const LLMQore::Acp::AgentCapabilities &capabilities)
{
QStringList prompt;
if (capabilities.promptCapabilities.image)
prompt.append(AgentTester::tr("image"));
if (capabilities.promptCapabilities.audio)
prompt.append(AgentTester::tr("audio"));
if (capabilities.promptCapabilities.embeddedContext)
prompt.append(AgentTester::tr("embedded context"));
return prompt.join(QStringLiteral(", "));
}
QString mcpList(const LLMQore::Acp::AgentCapabilities &capabilities)
{
QStringList transports;
if (capabilities.mcpCapabilities.http)
transports.append(QStringLiteral("http"));
if (capabilities.mcpCapabilities.sse)
transports.append(QStringLiteral("sse"));
return transports.join(QStringLiteral(", "));
}
QString describe(const AgentDefinition &agent, const LLMQore::Acp::InitializeResult &result)
{
const QString name = result.agentInfo && !result.agentInfo->name.isEmpty()
? result.agentInfo->name
: agent.name;
const QString version = result.agentInfo && !result.agentInfo->version.isEmpty()
? result.agentInfo->version
: agent.version;
QStringList lines;
lines.append(version.isEmpty() ? name : QStringLiteral("%1 %2").arg(name, version));
lines.append(AgentTester::tr("Protocol version: %1").arg(result.protocolVersion));
lines.append(
AgentTester::tr("Session persistence (loadSession): %1")
.arg(
result.agentCapabilities.loadSession ? AgentTester::tr("supported")
: AgentTester::tr("not supported")));
const QString prompt = capabilityList(result.agentCapabilities);
lines.append(
AgentTester::tr("Prompt content: %1")
.arg(prompt.isEmpty() ? AgentTester::tr("text only") : prompt));
const QString mcp = mcpList(result.agentCapabilities);
if (!mcp.isEmpty())
lines.append(AgentTester::tr("MCP transports: %1").arg(mcp));
if (result.authMethods.isEmpty()) {
lines.append(AgentTester::tr("Authentication: not required"));
} else {
QStringList methods;
for (const LLMQore::Acp::AuthMethod &method : result.authMethods)
methods.append(method.name.isEmpty() ? method.id : method.name);
lines.append(AgentTester::tr("Authentication: %1").arg(methods.join(QStringLiteral(", "))));
}
return lines.join(QLatin1Char('\n'));
}
} // namespace
AgentTester::AgentTester(QObject *parent)
: QObject(parent)
{}
void AgentTester::start(const AgentDefinition &agent, const QString &workingDirectory)
{
if (m_running)
return;
const AgentProcess process = spawnAgent(agent, workingDirectory, this);
if (!process.client) {
emit finished(false, tr("This agent has no launchable distribution."));
return;
}
m_stderr.clear();
m_runner = process.command;
m_running = true;
m_client = process.client;
m_transport = qobject_cast<LLMQore::Rpc::StdioClientTransport *>(m_client->transport());
connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this](const QString &line) {
if (m_stderr.size() < maxStderrLines)
m_stderr.append(line);
});
connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this](const QString &error) {
report(false, error);
});
m_client->connectAndInitialize(std::chrono::seconds(60))
.then(
this,
[this, agent](const LLMQore::Acp::InitializeResult &result) {
report(true, describe(agent, result));
})
.onFailed(this, [this](const std::exception &e) {
report(false, QString::fromUtf8(e.what()));
});
}
void AgentTester::cancel()
{
if (m_running)
report(false, tr("Test cancelled."));
}
void AgentTester::report(bool ok, const QString &text)
{
if (!m_running)
return;
m_running = false;
const QString details = ok ? text : text + diagnostics();
releaseClient();
emit finished(ok, details);
}
QString AgentTester::diagnostics() const
{
QString result;
const QString hint = runnerHint(m_runner);
if (!hint.isEmpty())
result += QLatin1Char('\n') + hint;
if (!m_stderr.isEmpty()) {
result += QLatin1Char('\n') + tr("Agent output:") + QLatin1Char('\n')
+ m_stderr.join(QLatin1Char('\n'));
}
return result;
}
void AgentTester::releaseClient()
{
LLMQore::Acp::AcpClient *client = m_client;
LLMQore::Rpc::StdioClientTransport *transport = m_transport;
m_client = nullptr;
m_transport = nullptr;
if (client)
client->disconnect(this);
QMetaObject::invokeMethod(
this,
[client, transport]() {
if (client) {
client->shutdown();
client->deleteLater();
}
if (transport)
transport->deleteLater();
},
Qt::QueuedConnection);
}
} // namespace QodeAssist::Acp
+50
View File
@@ -0,0 +1,50 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include "acp/AgentDefinition.hpp"
namespace LLMQore::Acp {
class AcpClient;
}
namespace LLMQore::Rpc {
class StdioClientTransport;
}
namespace QodeAssist::Acp {
class AgentTester : public QObject
{
Q_OBJECT
public:
explicit AgentTester(QObject *parent = nullptr);
void start(const AgentDefinition &agent, const QString &workingDirectory);
void cancel();
bool isRunning() const { return m_running; }
signals:
void finished(bool ok, const QString &report);
private:
void report(bool ok, const QString &text);
void releaseClient();
QString diagnostics() const;
LLMQore::Acp::AcpClient *m_client = nullptr;
LLMQore::Rpc::StdioClientTransport *m_transport = nullptr;
QStringList m_stderr;
QString m_runner;
bool m_running = false;
};
} // namespace QodeAssist::Acp
+29
View File
@@ -0,0 +1,29 @@
add_library(QodeAssistAcp STATIC
AgentBinding.hpp AgentBinding.cpp
AgentDefinition.hpp AgentDefinition.cpp
AgentRegistryParser.hpp AgentRegistryParser.cpp
AgentCatalog.hpp AgentCatalog.cpp
AgentLaunch.hpp AgentLaunch.cpp
AgentCatalogStore.hpp AgentCatalogStore.cpp
AgentSpawn.hpp AgentSpawn.cpp
AgentTester.hpp AgentTester.cpp
ChatPermissionProvider.hpp ChatPermissionProvider.cpp
AcpChatBackend.hpp AcpChatBackend.cpp
)
target_link_libraries(QodeAssistAcp
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
QtCreator::ProjectExplorer
QtCreator::ExtensionSystem
LLMQore
QodeAssistSession
PRIVATE
QodeAssistLogger
QodeAssistSettings
)
target_include_directories(QodeAssistAcp PUBLIC ${CMAKE_SOURCE_DIR}/sources)
+56
View File
@@ -0,0 +1,56 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatPermissionProvider.hpp"
#include <memory>
#include <utility>
#include <QPromise>
namespace QodeAssist::Acp {
ChatPermissionProvider::ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent)
: LLMQore::Acp::AcpPermissionProvider(parent)
, m_ledger(ledger)
{}
void ChatPermissionProvider::setRequestHandler(RequestHandler handler)
{
m_requestHandler = std::move(handler);
}
QFuture<LLMQore::Acp::RequestPermissionResult> ChatPermissionProvider::requestPermission(
const QString &sessionId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options)
{
Q_UNUSED(sessionId)
auto promise = std::make_shared<QPromise<LLMQore::Acp::RequestPermissionResult>>();
promise->start();
const auto finish = [promise](const LLMQore::Acp::RequestPermissionResult &result) {
promise->addResult(result);
promise->finish();
};
if (!m_requestHandler) {
finish(LLMQore::Acp::RequestPermissionResult::cancelled());
return promise->future();
}
const QString requestId = m_ledger->registerPermission(
[finish](const QString &optionId) {
finish(LLMQore::Acp::RequestPermissionResult::selected(optionId));
},
[finish] { finish(LLMQore::Acp::RequestPermissionResult::cancelled()); });
QFuture<LLMQore::Acp::RequestPermissionResult> future = promise->future();
m_requestHandler(requestId, toolCall, options);
return future;
}
} // namespace QodeAssist::Acp
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <QString>
#include <LLMQore/AcpPermissionProvider.hpp>
#include "session/TurnLedger.hpp"
namespace QodeAssist::Acp {
class ChatPermissionProvider : public LLMQore::Acp::AcpPermissionProvider
{
Q_OBJECT
public:
using RequestHandler = std::function<void(
const QString &requestId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options)>;
explicit ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent = nullptr);
void setRequestHandler(RequestHandler handler);
QFuture<LLMQore::Acp::RequestPermissionResult> requestPermission(
const QString &sessionId,
const LLMQore::Acp::ToolCall &toolCall,
const QList<LLMQore::Acp::PermissionOption> &options) override;
private:
RequestHandler m_requestHandler;
Session::TurnLedger *m_ledger = nullptr;
};
} // namespace QodeAssist::Acp
-12
View File
@@ -15,7 +15,6 @@
#include "logger/Logger.hpp"
#include "settings/CodeCompletionSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include "context/RulesLoader.hpp"
namespace QodeAssist {
@@ -283,17 +282,6 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
? m_completeSettings.systemPromptForNonFimModels()
: m_completeSettings.systemPrompt());
auto project = Context::RulesLoader::getActiveProject();
if (project) {
QString projectRules
= Context::RulesLoader::loadRulesForProject(project, Context::RulesContext::Completions);
if (!projectRules.isEmpty()) {
systemPrompt += "\n\n# Project Rules\n\n" + projectRules;
LOG_MESSAGE("Loaded project rules for completion");
}
}
if (updatedContext.fileContext.has_value())
systemPrompt.append(updatedContext.fileContext.value());
-5
View File
@@ -45,7 +45,6 @@
#include "settings/QuickRefactorSettings.hpp"
#include "widgets/RefactorWidgetHandler.hpp"
#include "refactor/RefactorContextHelper.hpp"
#include <context/ChangesManager.h>
#include <logger/Logger.hpp>
using namespace LanguageServerProtocol;
@@ -201,10 +200,6 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
if (!textEditor || textEditor->document() != document)
return;
if (Settings::codeCompletionSettings().useProjectChangesCache())
Context::ChangesManager::instance()
.addChange(document, position, charsRemoved, charsAdded);
TextEditorWidget *widget = textEditor->editorWidget();
if (widget->isReadOnly() || widget->multiTextCursor().hasMultipleCursors())
return;
+1 -2
View File
@@ -1,6 +1,6 @@
add_library(Context STATIC
DocumentContextReader.hpp DocumentContextReader.cpp
ChangesManager.h ChangesManager.cpp
FileEditManager.hpp FileEditManager.cpp
ContextManager.hpp ContextManager.cpp
ContentFile.hpp
DocumentReaderQtCreator.hpp
@@ -10,7 +10,6 @@ add_library(Context STATIC
IContextManager.hpp
IgnoreManager.hpp IgnoreManager.cpp
ProjectUtils.hpp ProjectUtils.cpp
RulesLoader.hpp RulesLoader.cpp
)
target_link_libraries(Context
+1
View File
@@ -12,6 +12,7 @@ struct ContentFile
{
QString filename;
QString content;
QString path;
};
} // namespace QodeAssist::Context
+12 -4
View File
@@ -41,9 +41,9 @@ QString ContextManager::readFile(const QString &filePath) const
return content;
}
QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths) const
QStringList ContextManager::allowedPaths(const QStringList &filePaths) const
{
QList<ContentFile> files;
QStringList allowed;
for (const QString &path : filePaths) {
auto project = ProjectExplorer::ProjectManager::projectForFile(
Utils::FilePath::fromString(path));
@@ -52,9 +52,16 @@ QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths)
continue;
}
ContentFile contentFile = createContentFile(path);
files.append(contentFile);
allowed.append(path);
}
return allowed;
}
QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths) const
{
QList<ContentFile> files;
for (const QString &path : allowedPaths(filePaths))
files.append(createContentFile(path));
return files;
}
@@ -85,6 +92,7 @@ ContentFile ContextManager::createContentFile(const QString &filePath) const
QFileInfo fileInfo(filePath);
contentFile.filename = fileInfo.fileName();
contentFile.content = readFile(filePath);
contentFile.path = fileInfo.absoluteFilePath();
return contentFile;
}
+1
View File
@@ -27,6 +27,7 @@ public:
~ContextManager() override = default;
QString readFile(const QString &filePath) const override;
QStringList allowedPaths(const QStringList &filePaths) const override;
QList<ContentFile> getContentFiles(const QStringList &filePaths) const override;
QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const override;
ContentFile createContentFile(const QString &filePath) const override;
@@ -10,8 +10,6 @@
#include "CodeCompletionSettings.hpp"
#include "ChangesManager.h"
const QRegularExpression &getYearRegex()
{
static const QRegularExpression yearRegex("\\b(19|20)\\d{2}\\b");
@@ -274,10 +272,6 @@ LLMCore::ContextData DocumentContextReader::prepareContext(
QString fileContext;
fileContext.append("\n ").append(getLanguageAndFileInfo());
if (settings.useProjectChangesCache())
fileContext.append("Recent Project Changes Context:\n ")
.append(ChangesManager::instance().getRecentChangesContext(m_textDocument));
return {.prefix = contextBefore, .suffix = contextAfter, .fileContext = fileContext};
}
@@ -2,11 +2,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChangesManager.h"
#include "CodeCompletionSettings.hpp"
#include "FileEditManager.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/editormanager/ieditor.h>
#include <texteditor/textdocument.h>
#include <logger/Logger.hpp>
#include <algorithm>
#include <QFile>
@@ -15,61 +15,20 @@
namespace QodeAssist::Context {
ChangesManager &ChangesManager::instance()
FileEditManager &FileEditManager::instance()
{
static ChangesManager instance;
static FileEditManager instance;
return instance;
}
ChangesManager::ChangesManager()
FileEditManager::FileEditManager()
: QObject(nullptr)
, m_undoStack(new QUndoStack(this))
{}
ChangesManager::~ChangesManager() {}
FileEditManager::~FileEditManager() {}
void ChangesManager::addChange(
TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded)
{
auto &documentQueue = m_documentChanges[document];
QTextBlock block = document->document()->findBlock(position);
int lineNumber = block.blockNumber();
QString lineContent = block.text();
QString fileName = document->filePath().fileName();
ChangeInfo change{fileName, lineNumber, lineContent};
auto it
= std::find_if(documentQueue.begin(), documentQueue.end(), [lineNumber](const ChangeInfo &c) {
return c.lineNumber == lineNumber;
});
if (it != documentQueue.end()) {
it->lineContent = lineContent;
} else {
documentQueue.enqueue(change);
if (documentQueue.size() > Settings::codeCompletionSettings().maxChangesCacheSize()) {
documentQueue.dequeue();
}
}
}
QString ChangesManager::getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const
{
QString context;
for (auto it = m_documentChanges.constBegin(); it != m_documentChanges.constEnd(); ++it) {
if (it.key() != currentDocument) {
for (const auto &change : it.value()) {
context += change.lineContent + "\n";
}
}
}
return context;
}
void ChangesManager::addFileEdit(
void FileEditManager::addFileEdit(
const QString &editId,
const QString &filePath,
const QString &oldContent,
@@ -139,7 +98,44 @@ void ChangesManager::addFileEdit(
}
}
bool ChangesManager::applyFileEdit(const QString &editId)
void FileEditManager::registerAppliedFileEdit(
const QString &editId,
const QString &filePath,
const QString &oldContent,
const QString &newContent,
const QString &requestId)
{
QMutexLocker locker(&m_mutex);
if (m_fileEdits.contains(editId)) {
LOG_MESSAGE(QString("File edit already exists, skipping: %1").arg(editId));
return;
}
FileEdit edit;
edit.editId = editId;
edit.filePath = filePath;
edit.oldContent = oldContent;
edit.newContent = newContent;
edit.timestamp = QDateTime::currentDateTime();
edit.wasAutoApplied = true;
edit.isFromHistory = false;
edit.status = Applied;
edit.statusMessage = "Applied by agent";
m_fileEdits.insert(editId, edit);
if (!requestId.isEmpty())
m_requestEdits[requestId].editIds.append(editId);
locker.unlock();
emit fileEditAdded(editId);
LOG_MESSAGE(QString("Agent file edit registered as applied: %1 for file %2")
.arg(editId, filePath));
}
bool FileEditManager::applyFileEdit(const QString &editId)
{
QMutexLocker locker(&m_mutex);
@@ -193,7 +189,7 @@ bool ChangesManager::applyFileEdit(const QString &editId)
return false;
}
bool ChangesManager::rejectFileEdit(const QString &editId)
bool FileEditManager::rejectFileEdit(const QString &editId)
{
QMutexLocker locker(&m_mutex);
@@ -220,7 +216,7 @@ bool ChangesManager::rejectFileEdit(const QString &editId)
return true;
}
bool ChangesManager::undoFileEdit(const QString &editId)
bool FileEditManager::undoFileEdit(const QString &editId)
{
QMutexLocker locker(&m_mutex);
@@ -276,13 +272,13 @@ bool ChangesManager::undoFileEdit(const QString &editId)
return false;
}
ChangesManager::FileEdit ChangesManager::getFileEdit(const QString &editId) const
FileEditManager::FileEdit FileEditManager::getFileEdit(const QString &editId) const
{
QMutexLocker locker(&m_mutex);
return m_fileEdits.value(editId);
}
QList<ChangesManager::FileEdit> ChangesManager::getPendingEdits() const
QList<FileEditManager::FileEdit> FileEditManager::getPendingEdits() const
{
QMutexLocker locker(&m_mutex);
@@ -295,7 +291,7 @@ QList<ChangesManager::FileEdit> ChangesManager::getPendingEdits() const
return pendingEdits;
}
bool ChangesManager::performFileEdit(
bool FileEditManager::performFileEdit(
const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg)
{
auto setError = [errorMsg](const QString &msg) {
@@ -451,7 +447,7 @@ bool ChangesManager::performFileEdit(
return true;
}
int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) const
int FileEditManager::levenshteinDistance(const QString &s1, const QString &s2) const
{
const int len1 = s1.length();
const int len2 = s2.length();
@@ -484,7 +480,7 @@ int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) co
return d[len1][len2];
}
QString ChangesManager::findBestMatchLineBased(
QString FileEditManager::findBestMatchLineBased(
const QString &fileContent,
const QString &searchContent,
double threshold,
@@ -550,7 +546,7 @@ QString ChangesManager::findBestMatchLineBased(
return bestMatch;
}
QString ChangesManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const
QString FileEditManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const
{
if (searchContent.isEmpty() || fileContent.isEmpty()) {
if (outSimilarity) *outSimilarity = 0.0;
@@ -631,7 +627,7 @@ QString ChangesManager::findBestMatch(const QString &fileContent, const QString
return bestMatch;
}
QString ChangesManager::findBestMatchWithNormalization(
QString FileEditManager::findBestMatchWithNormalization(
const QString &fileContent,
const QString &searchContent,
double *outSimilarity,
@@ -677,7 +673,7 @@ QString ChangesManager::findBestMatchWithNormalization(
return QString();
}
bool ChangesManager::performFragmentReplacement(
bool FileEditManager::performFragmentReplacement(
const QString &filePath,
const QString &searchContent,
const QString &replaceContent,
@@ -821,7 +817,7 @@ bool ChangesManager::performFragmentReplacement(
return true;
}
bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg)
bool FileEditManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg)
{
QMutexLocker locker(&m_mutex);
@@ -857,7 +853,7 @@ bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QStri
return true;
}
QList<ChangesManager::FileEdit> ChangesManager::getEditsForRequest(const QString &requestId) const
QList<FileEditManager::FileEdit> FileEditManager::getEditsForRequest(const QString &requestId) const
{
QMutexLocker locker(&m_mutex);
@@ -877,7 +873,7 @@ QList<ChangesManager::FileEdit> ChangesManager::getEditsForRequest(const QString
return edits;
}
bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg)
bool FileEditManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg)
{
QMutexLocker locker(&m_mutex);
@@ -962,7 +958,7 @@ bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *e
return true;
}
bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg)
bool FileEditManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg)
{
QMutexLocker locker(&m_mutex);
@@ -1046,7 +1042,7 @@ bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString
return true;
}
void ChangesManager::archiveAllNonArchivedEdits()
void FileEditManager::archiveAllNonArchivedEdits()
{
QMutexLocker locker(&m_mutex);
@@ -1080,7 +1076,7 @@ void ChangesManager::archiveAllNonArchivedEdits()
}
}
QString ChangesManager::readFileContent(const QString &filePath) const
QString FileEditManager::readFileContent(const QString &filePath) const
{
LOG_MESSAGE(QString("Reading current file content: %1").arg(filePath));
@@ -1112,7 +1108,7 @@ QString ChangesManager::readFileContent(const QString &filePath) const
return content;
}
bool ChangesManager::performFileEditWithDiff(
bool FileEditManager::performFileEditWithDiff(
const QString &filePath,
const DiffInfo &diffInfo,
bool reverse,
@@ -1244,7 +1240,7 @@ bool ChangesManager::performFileEditWithDiff(
return true;
}
ChangesManager::DiffInfo ChangesManager::createDiffInfo(
FileEditManager::DiffInfo FileEditManager::createDiffInfo(
const QString &originalContent,
const QString &modifiedContent,
const QString &filePath)
@@ -1390,7 +1386,7 @@ ChangesManager::DiffInfo ChangesManager::createDiffInfo(
return diffInfo;
}
bool ChangesManager::findHunkLocation(
bool FileEditManager::findHunkLocation(
const QStringList &fileLines,
const DiffHunk &hunk,
int &actualStartLine,
@@ -1537,7 +1533,7 @@ bool ChangesManager::findHunkLocation(
return false;
}
bool ChangesManager::applyDiffToContent(
bool FileEditManager::applyDiffToContent(
QString &content,
const DiffInfo &diffInfo,
bool reverse,
@@ -4,28 +4,22 @@
#pragma once
#include <texteditor/textdocument.h>
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QMutex>
#include <QQueue>
#include <QTimer>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QUndoStack>
namespace QodeAssist::Context {
class ChangesManager : public QObject
class FileEditManager : public QObject
{
Q_OBJECT
public:
struct ChangeInfo
{
QString fileName;
int lineNumber;
QString lineContent;
};
enum FileEditStatus { Pending, Applied, Rejected, Archived };
struct DiffHunk
@@ -63,11 +57,7 @@ public:
QString statusMessage;
};
static ChangesManager &instance();
void addChange(
TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded);
QString getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const;
static FileEditManager &instance();
void addFileEdit(
const QString &editId,
@@ -77,20 +67,26 @@ public:
bool autoApply = true,
bool isFromHistory = false,
const QString &requestId = QString());
void registerAppliedFileEdit(
const QString &editId,
const QString &filePath,
const QString &oldContent,
const QString &newContent,
const QString &requestId = QString());
bool applyFileEdit(const QString &editId);
bool rejectFileEdit(const QString &editId);
bool undoFileEdit(const QString &editId);
FileEdit getFileEdit(const QString &editId) const;
QList<FileEdit> getPendingEdits() const;
bool applyPendingEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
QList<FileEdit> getEditsForRequest(const QString &requestId) const;
bool undoAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
bool reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
void archiveAllNonArchivedEdits();
signals:
@@ -101,19 +97,19 @@ signals:
void fileEditArchived(const QString &editId);
private:
ChangesManager();
~ChangesManager();
ChangesManager(const ChangesManager &) = delete;
ChangesManager &operator=(const ChangesManager &) = delete;
FileEditManager();
~FileEditManager();
FileEditManager(const FileEditManager &) = delete;
FileEditManager &operator=(const FileEditManager &) = delete;
bool performFileEdit(const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg = nullptr);
bool performFileEditWithDiff(const QString &filePath, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr);
QString readFileContent(const QString &filePath) const;
DiffInfo createDiffInfo(const QString &originalContent, const QString &modifiedContent, const QString &filePath);
bool applyDiffToContent(QString &content, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr);
bool findHunkLocation(const QStringList &fileLines, const DiffHunk &hunk, int &actualStartLine, QString *debugInfo = nullptr) const;
// Helper method for fragment-based apply/undo operations
bool performFragmentReplacement(
const QString &filePath,
@@ -122,7 +118,7 @@ private:
bool isAppendOperation,
QString *errorMsg = nullptr,
bool isUndo = false);
int levenshteinDistance(const QString &s1, const QString &s2) const;
QString findBestMatch(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const;
QString findBestMatchLineBased(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const;
@@ -134,7 +130,6 @@ private:
bool autoApplyPending = false;
};
QHash<TextEditor::TextDocument *, QQueue<ChangeInfo>> m_documentChanges;
QHash<QString, FileEdit> m_fileEdits;
QHash<QString, RequestEdits> m_requestEdits; // requestId → ordered edits
QUndoStack *m_undoStack;
+1
View File
@@ -23,6 +23,7 @@ public:
virtual ~IContextManager() = default;
virtual QString readFile(const QString &filePath) const = 0;
virtual QStringList allowedPaths(const QStringList &filePaths) const = 0;
virtual QList<ContentFile> getContentFiles(const QStringList &filePaths) const = 0;
virtual QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const = 0;
virtual ContentFile createContentFile(const QString &filePath) const = 0;
-166
View File
@@ -1,166 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "context/RulesLoader.hpp"
#include <QDir>
#include <QFile>
#include <coreplugin/editormanager/editormanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
namespace QodeAssist::Context {
QString RulesLoader::loadRules(const QString &projectPath, RulesContext context)
{
if (projectPath.isEmpty()) {
return QString();
}
QString combined;
QString basePath = projectPath + "/.qodeassist/rules";
switch (context) {
case RulesContext::Completions:
combined += loadAllMarkdownFiles(basePath + "/completions");
break;
case RulesContext::Chat:
combined += loadAllMarkdownFiles(basePath + "/common");
combined += loadAllMarkdownFiles(basePath + "/chat");
break;
case RulesContext::QuickRefactor:
combined += loadAllMarkdownFiles(basePath + "/common");
combined += loadAllMarkdownFiles(basePath + "/quickrefactor");
break;
}
return combined;
}
QString RulesLoader::loadRulesForProject(ProjectExplorer::Project *project, RulesContext context)
{
if (!project) {
return QString();
}
QString projectPath = getProjectPath(project);
return loadRules(projectPath, context);
}
ProjectExplorer::Project *RulesLoader::getActiveProject()
{
auto currentEditor = Core::EditorManager::currentEditor();
if (currentEditor && currentEditor->document()) {
Utils::FilePath filePath = currentEditor->document()->filePath();
auto project = ProjectExplorer::ProjectManager::projectForFile(filePath);
if (project) {
return project;
}
}
return ProjectExplorer::ProjectManager::startupProject();
}
QString RulesLoader::loadAllMarkdownFiles(const QString &dirPath)
{
QString combined;
QDir dir(dirPath);
if (!dir.exists()) {
return QString();
}
QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name);
for (const QString &fileName : mdFiles) {
QFile file(dir.filePath(fileName));
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
combined += file.readAll();
combined += "\n\n";
}
}
return combined;
}
QString RulesLoader::getProjectPath(ProjectExplorer::Project *project)
{
if (!project) {
return QString();
}
return project->projectDirectory().toUrlishString();
}
QVector<RuleFileInfo> RulesLoader::getRuleFiles(const QString &projectPath, RulesContext context)
{
if (projectPath.isEmpty()) {
return QVector<RuleFileInfo>();
}
QVector<RuleFileInfo> result;
QString basePath = projectPath + "/.qodeassist/rules";
// Always include common rules
result.append(collectMarkdownFiles(basePath + "/common", "common"));
// Add context-specific rules
switch (context) {
case RulesContext::Completions:
result.append(collectMarkdownFiles(basePath + "/completions", "completions"));
break;
case RulesContext::Chat:
result.append(collectMarkdownFiles(basePath + "/chat", "chat"));
break;
case RulesContext::QuickRefactor:
result.append(collectMarkdownFiles(basePath + "/quickrefactor", "quickrefactor"));
break;
}
return result;
}
QVector<RuleFileInfo> RulesLoader::getRuleFilesForProject(
ProjectExplorer::Project *project, RulesContext context)
{
if (!project) {
return QVector<RuleFileInfo>();
}
QString projectPath = getProjectPath(project);
return getRuleFiles(projectPath, context);
}
QString RulesLoader::loadRuleFileContent(const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QString();
}
return file.readAll();
}
QVector<RuleFileInfo> RulesLoader::collectMarkdownFiles(
const QString &dirPath, const QString &category)
{
QVector<RuleFileInfo> result;
QDir dir(dirPath);
if (!dir.exists()) {
return result;
}
QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name);
for (const QString &fileName : mdFiles) {
QString fullPath = dir.filePath(fileName);
result.append({fullPath, fileName, category});
}
return result;
}
} // namespace QodeAssist::Context
-42
View File
@@ -1,42 +0,0 @@
// Copyright (C) 2025-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Context {
enum class RulesContext { Completions, Chat, QuickRefactor };
struct RuleFileInfo
{
QString filePath;
QString fileName;
QString category; // "common", "chat", "completions", "quickrefactor"
};
class RulesLoader
{
public:
static QString loadRules(const QString &projectPath, RulesContext context);
static QString loadRulesForProject(ProjectExplorer::Project *project, RulesContext context);
static ProjectExplorer::Project *getActiveProject();
// New methods for getting rule files info
static QVector<RuleFileInfo> getRuleFiles(const QString &projectPath, RulesContext context);
static QVector<RuleFileInfo> getRuleFilesForProject(ProjectExplorer::Project *project, RulesContext context);
static QString loadRuleFileContent(const QString &filePath);
private:
static QString loadAllMarkdownFiles(const QString &dirPath);
static QVector<RuleFileInfo> collectMarkdownFiles(const QString &dirPath, const QString &category);
static QString getProjectPath(ProjectExplorer::Project *project);
};
} // namespace QodeAssist::Context
+129
View File
@@ -0,0 +1,129 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentKnowledgeServer.hpp"
#include <QHostAddress>
#include <QUuid>
#include <LLMQore/McpHttpServerTransport.hpp>
#include <LLMQore/McpServer.hpp>
#include <LLMQore/McpTypes.hpp>
#include <LLMQore/Version.hpp>
#include <logger/Logger.hpp>
#include "tools/EditorStateTools.hpp"
#include "tools/GetIssuesListTool.hpp"
namespace QodeAssist::Mcp {
namespace {
QString knowledgeServerName()
{
return QStringLiteral("qodeassist");
}
} // namespace
AgentKnowledgeServer::AgentKnowledgeServer(QObject *parent)
: QObject(parent)
{}
AgentKnowledgeServer::~AgentKnowledgeServer()
{
stop();
}
QString AgentKnowledgeServer::serverName() const
{
return knowledgeServerName();
}
void AgentKnowledgeServer::setIgnorePredicate(std::function<bool(const QString &)> predicate)
{
m_ignorePredicate = std::move(predicate);
}
QStringList AgentKnowledgeServer::toolIds()
{
return {
Tools::ListOpenEditorsTool().id(),
Tools::GetEditorSelectionTool().id(),
Tools::GetProjectModelTool().id(),
Tools::GetIssuesListTool().id()};
}
QString AgentKnowledgeServer::endpointUrl() const
{
return m_runningPort == 0
? QString()
: QStringLiteral("http://127.0.0.1:%1/mcp/%2").arg(m_runningPort).arg(m_pathToken);
}
QString AgentKnowledgeServer::start()
{
if (m_runningPort != 0)
return endpointUrl();
m_pathToken = QUuid::createUuid().toString(QUuid::WithoutBraces);
::LLMQore::Mcp::HttpServerConfig transportConfig;
transportConfig.address = QHostAddress::LocalHost;
transportConfig.port = 0;
transportConfig.path = QStringLiteral("/mcp/%1").arg(m_pathToken);
m_transport = new ::LLMQore::Mcp::McpHttpServerTransport(transportConfig, this);
::LLMQore::Mcp::McpServerConfig serverConfig;
serverConfig.serverInfo = {"QodeAssist", QStringLiteral(LLMQORE_VERSION_STRING)};
serverConfig.instructions = tr(
"Read-only access to what Qt Creator knows about this session: files open in the editor "
"with their unsaved contents, the current selection, build issues, and the configured "
"kit and build directory. Use it for anything you cannot answer by reading the files on "
"disk yourself.");
m_server = new ::LLMQore::Mcp::McpServer(m_transport.data(), serverConfig, this);
auto *openEditors = new Tools::ListOpenEditorsTool(m_server);
openEditors->setIgnorePredicate(m_ignorePredicate);
m_server->addTool(openEditors);
m_server->addTool(new Tools::GetEditorSelectionTool(m_server));
m_server->addTool(new Tools::GetProjectModelTool(m_server));
m_server->addTool(new Tools::GetIssuesListTool(m_server));
m_server->start();
if (!m_transport->isOpen()) {
LOG_MESSAGE("The QodeAssist knowledge server could not bind a loopback port");
stop();
return {};
}
m_runningPort = m_transport->serverPort();
LOG_MESSAGE(QString("QodeAssist knowledge server listening on 127.0.0.1:%1").arg(m_runningPort));
return endpointUrl();
}
void AgentKnowledgeServer::stop()
{
if (m_server) {
m_server->stop();
m_server->deleteLater();
m_server.clear();
}
if (m_transport) {
m_transport->deleteLater();
m_transport.clear();
}
m_runningPort = 0;
m_pathToken.clear();
}
} // namespace QodeAssist::Mcp
+48
View File
@@ -0,0 +1,48 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <QObject>
#include <QPointer>
#include <QStringList>
#include "acp/AgentKnowledgeService.hpp"
namespace LLMQore::Mcp {
class McpServer;
class McpHttpServerTransport;
} // namespace LLMQore::Mcp
namespace QodeAssist::Mcp {
class AgentKnowledgeServer : public QObject, public Acp::AgentKnowledgeService
{
Q_OBJECT
public:
explicit AgentKnowledgeServer(QObject *parent = nullptr);
~AgentKnowledgeServer() override;
QString start() override;
void stop() override;
QString serverName() const override;
void setIgnorePredicate(std::function<bool(const QString &)> predicate);
quint16 runningPort() const { return m_runningPort; }
QString endpointUrl() const;
static QStringList toolIds();
private:
QPointer<::LLMQore::Mcp::McpServer> m_server;
QPointer<::LLMQore::Mcp::McpHttpServerTransport> m_transport;
std::function<bool(const QString &)> m_ignorePredicate;
QString m_pathToken;
quint16 m_runningPort = 0;
};
} // namespace QodeAssist::Mcp
+13 -3
View File
@@ -60,8 +60,12 @@
#include "templates/Templates.hpp"
#include "widgets/CustomInstructionsManager.hpp"
#include "widgets/QuickRefactorDialog.hpp"
#include <QQmlContext>
#include <ChatView/ChatView.hpp>
#include <ChatView/ChatFileManager.hpp>
#include <acp/AgentCatalogStore.hpp>
#include <settings/AgentsWidget.hpp>
#include <ChatView/AttachmentStaging.hpp>
#include <ChatView/ChatRootView.hpp>
#include <ChatView/ChatWidget.hpp>
#include <ChatView/SessionFileRegistry.hpp>
@@ -96,7 +100,7 @@ public:
~QodeAssistPlugin() final
{
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
Chat::AttachmentStaging::cleanupGlobalIntermediateStorage();
delete m_qodeAssistClient;
if (m_chatOutputPane) {
@@ -176,6 +180,10 @@ public:
m_engine = new QQmlEngine{this};
m_sessionFileRegistry = new Chat::SessionFileRegistry{this};
m_skillsManager = new Skills::SkillsManager{this};
m_agentCatalog = new Acp::AgentCatalogStore{this};
m_agentCatalog->reload();
m_engine->rootContext()->setContextProperty("agentCatalog", m_agentCatalog);
m_agentsSettingsPage = std::make_unique<Settings::AgentsSettingsPage>(m_agentCatalog);
{
auto &providers = Providers::ProvidersManager::instance();
@@ -308,7 +316,7 @@ public:
Core::Constants::G_DEFAULT_THREE);
}
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
Chat::AttachmentStaging::cleanupGlobalIntermediateStorage();
#ifdef WITH_TESTS
addTest<QodeAssistTest>();
@@ -499,6 +507,8 @@ private:
QPointer<Mcp::McpServerManager> m_mcpServerManager;
QPointer<QQmlEngine> m_engine;
QPointer<Skills::SkillsManager> m_skillsManager;
QPointer<Acp::AgentCatalogStore> m_agentCatalog;
std::unique_ptr<Settings::AgentsSettingsPage> m_agentsSettingsPage;
};
} // namespace QodeAssist::Internal
-12
View File
@@ -15,7 +15,6 @@
#include <context/Utils.hpp>
#include "templates/PromptTemplateManager.hpp"
#include "providers/ProvidersManager.hpp"
#include "context/RulesLoader.hpp"
#include <logger/Logger.hpp>
#include <settings/ChatAssistantSettings.hpp>
#include <settings/GeneralSettings.hpp>
@@ -270,17 +269,6 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
QString systemPrompt = Settings::quickRefactorSettings().systemPrompt();
auto project = Context::RulesLoader::getActiveProject();
if (project) {
QString projectRules = Context::RulesLoader::loadRulesForProject(
project, Context::RulesContext::QuickRefactor);
if (!projectRules.isEmpty()) {
systemPrompt += "\n\n# Project Rules\n\n" + projectRules;
LOG_MESSAGE("Loaded project rules for quick refactor");
}
}
systemPrompt += "\n\nFile information:";
systemPrompt += "\nLanguage: " + documentInfo.mimeType;
systemPrompt += "\nFile path: " + documentInfo.filePath;
+57
View File
@@ -0,0 +1,57 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/AgentPlan.hpp"
#include <QJsonArray>
#include "session/BlockCodec.hpp"
namespace QodeAssist::Session {
QJsonObject planBlockToJson(const PlanBlock &block)
{
QJsonArray entries;
for (const PlanEntry &entry : block.entries) {
QJsonObject json{{"content", entry.content}, {"status", entry.status}};
if (!entry.priority.isEmpty())
json["priority"] = entry.priority;
entries.append(json);
}
return QJsonObject{{"entries", entries}};
}
PlanBlock planBlockFromJson(const QJsonObject &json)
{
PlanBlock block;
const QJsonArray entries = json["entries"].toArray();
for (const QJsonValue &value : entries) {
const QJsonObject entry = value.toObject();
block.entries.append(
PlanEntry{
entry["content"].toString(),
entry["priority"].toString(),
entry["status"].toString()});
}
return block;
}
QString encodePlanBlock(const PlanBlock &block)
{
return encodeMarkerPayload(planPayloadMarker, planBlockToJson(block));
}
std::optional<PlanBlock> decodePlanBlock(const QString &text)
{
const auto payload = decodeMarkerPayload(planPayloadMarker, text);
if (!payload)
return std::nullopt;
return planBlockFromJson(*payload);
}
} // namespace QodeAssist::Session

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