mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-23 19:51:05 -04:00
feat: add support acp in common chat (#369)
This commit is contained in:
373
sources/ChatView/ConversationCoordinator.cpp
Normal file
373
sources/ChatView/ConversationCoordinator.cpp
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user