mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-09-10 19:27:05 -04:00
feat: add support acp in common chat (#369)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+305
-434
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ¤tQuery,
|
||||
bool useTools)
|
||||
const QString ¤tQuery)
|
||||
{
|
||||
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 + " ";
|
||||
|
||||
@@ -36,11 +36,9 @@ public:
|
||||
const QString &absolutePath,
|
||||
const QString &relativePath,
|
||||
const QString &projectName,
|
||||
const QString ¤tQuery,
|
||||
bool useTools);
|
||||
const QString ¤tQuery);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ⌖
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -12,6 +12,7 @@ struct ContentFile
|
||||
{
|
||||
QString filename;
|
||||
QString content;
|
||||
QString path;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+1
-1
Submodule sources/external/llmqore updated: 3f9c8f5253...1e7088ad32
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user