Files
QodeAssist/sources/ChatView/ConversationCoordinator.cpp
2026-07-21 13:10:00 +02:00

374 lines
9.6 KiB
C++

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