mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-06 13:09:15 -04:00
fix: Found and fix review mistakes
This commit is contained in:
@@ -22,9 +22,12 @@ Q_LOGGING_CATEGORY(ctxLog, "qodeassist.context")
|
||||
QString roleToWireString(Message::Role role)
|
||||
{
|
||||
switch (role) {
|
||||
case Message::Role::System: return QStringLiteral("system");
|
||||
case Message::Role::User: return QStringLiteral("user");
|
||||
case Message::Role::Assistant: return QStringLiteral("assistant");
|
||||
case Message::Role::System:
|
||||
return QStringLiteral("system");
|
||||
case Message::Role::User:
|
||||
return QStringLiteral("user");
|
||||
case Message::Role::Assistant:
|
||||
return QStringLiteral("assistant");
|
||||
}
|
||||
return QStringLiteral("user");
|
||||
}
|
||||
@@ -70,7 +73,9 @@ QString Manifest::summary() const
|
||||
if (unsupportedBlocks > 0)
|
||||
s += QStringLiteral(", unsupported=%1").arg(unsupportedBlocks);
|
||||
if (!elided.isEmpty())
|
||||
s += QStringLiteral(", elided=%1 [%2]").arg(elided.size()).arg(elided.join(QStringLiteral("; ")));
|
||||
s += QStringLiteral(", elided=%1 [%2]")
|
||||
.arg(elided.size())
|
||||
.arg(elided.join(QStringLiteral("; ")));
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -135,8 +140,7 @@ Templates::ContextData assemble(
|
||||
e.kind = ContentBlockEntry::Kind::Image;
|
||||
e.imageData = img->data();
|
||||
e.mediaType = img->mediaType();
|
||||
e.isImageUrl
|
||||
= (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url);
|
||||
e.isImageUrl = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url);
|
||||
blockEntries.append(std::move(e));
|
||||
++manifest.imageBlocks;
|
||||
} else if (auto *si = dynamic_cast<StoredImageContent *>(block)) {
|
||||
@@ -144,8 +148,7 @@ Templates::ContextData assemble(
|
||||
if (base64.isEmpty()) {
|
||||
blockEntries.append(
|
||||
makeTextEntry(placeholderFor(QStringLiteral("Image"), si->fileName())));
|
||||
manifest.elided
|
||||
<< QStringLiteral("image unavailable: %1").arg(si->fileName());
|
||||
manifest.elided << QStringLiteral("image unavailable: %1").arg(si->fileName());
|
||||
qCWarning(ctxLog).noquote()
|
||||
<< "stored image unavailable, placeholder inserted:" << si->fileName();
|
||||
continue;
|
||||
@@ -165,8 +168,7 @@ Templates::ContextData assemble(
|
||||
manifest.elided
|
||||
<< QStringLiteral("attachment unavailable: %1").arg(sa->fileName());
|
||||
qCWarning(ctxLog).noquote()
|
||||
<< "stored attachment unavailable, placeholder inserted:"
|
||||
<< sa->fileName();
|
||||
<< "stored attachment unavailable, placeholder inserted:" << sa->fileName();
|
||||
continue;
|
||||
}
|
||||
const QString text = QString::fromUtf8(QByteArray::fromBase64(stored.toUtf8()));
|
||||
@@ -199,8 +201,7 @@ Templates::ContextData assemble(
|
||||
blockEntries.append(std::move(e));
|
||||
} else if (auto *tu = dynamic_cast<LLMQore::ToolUseContent *>(block)) {
|
||||
if (!resolvedToolUseIds.contains(tu->id())) {
|
||||
manifest.elided
|
||||
<< QStringLiteral("orphan tool_use dropped: %1").arg(tu->id());
|
||||
manifest.elided << QStringLiteral("orphan tool_use dropped: %1").arg(tu->id());
|
||||
continue;
|
||||
}
|
||||
ContentBlockEntry e;
|
||||
@@ -233,11 +234,11 @@ Templates::ContextData assemble(
|
||||
if (blockEntries.isEmpty())
|
||||
continue;
|
||||
|
||||
const bool hasNonThinking = std::any_of(
|
||||
blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) {
|
||||
return e.kind != ContentBlockEntry::Kind::Thinking
|
||||
&& e.kind != ContentBlockEntry::Kind::RedactedThinking;
|
||||
});
|
||||
const bool hasNonThinking
|
||||
= std::any_of(blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) {
|
||||
return e.kind != ContentBlockEntry::Kind::Thinking
|
||||
&& e.kind != ContentBlockEntry::Kind::RedactedThinking;
|
||||
});
|
||||
if (!hasNonThinking) {
|
||||
manifest.elided << QStringLiteral("thinking-only message dropped");
|
||||
continue;
|
||||
|
||||
@@ -33,7 +33,7 @@ void ConversationHistory::appendTextDeltaToLast(const QString &delta)
|
||||
return;
|
||||
|
||||
auto &last = m_messages.back();
|
||||
if (auto *text = last.lastBlockOfType<LLMQore::TextContent>()) {
|
||||
if (auto *text = dynamic_cast<LLMQore::TextContent *>(last.lastBlock())) {
|
||||
text->appendText(delta);
|
||||
} else {
|
||||
last.appendBlock(std::make_unique<LLMQore::TextContent>(delta));
|
||||
@@ -41,22 +41,16 @@ void ConversationHistory::appendTextDeltaToLast(const QString &delta)
|
||||
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
|
||||
}
|
||||
|
||||
void ConversationHistory::appendThinkingDeltaToLast(const QString &delta, const QString &signature)
|
||||
void ConversationHistory::appendThinkingBlockToLast(const QString &thinking, const QString &signature)
|
||||
{
|
||||
if (m_messages.empty() || (delta.isEmpty() && signature.isEmpty()))
|
||||
if (m_messages.empty() || (thinking.isEmpty() && signature.isEmpty()))
|
||||
return;
|
||||
|
||||
auto &last = m_messages.back();
|
||||
auto *thinking = last.lastBlockOfType<LLMQore::ThinkingContent>();
|
||||
if (!thinking) {
|
||||
auto fresh = std::make_unique<LLMQore::ThinkingContent>(delta, signature);
|
||||
last.appendBlock(std::move(fresh));
|
||||
} else {
|
||||
if (!delta.isEmpty())
|
||||
thinking->appendThinking(delta);
|
||||
if (!signature.isEmpty())
|
||||
thinking->setSignature(signature);
|
||||
}
|
||||
if (thinking.isEmpty())
|
||||
last.appendBlock(std::make_unique<LLMQore::RedactedThinkingContent>(signature));
|
||||
else
|
||||
last.appendBlock(std::make_unique<LLMQore::ThinkingContent>(thinking, signature));
|
||||
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ public:
|
||||
void appendBlockToLast(std::unique_ptr<LLMQore::ContentBlock> block);
|
||||
|
||||
void appendTextDeltaToLast(const QString &delta);
|
||||
void appendThinkingDeltaToLast(const QString &delta, const QString &signature = QString());
|
||||
void appendThinkingBlockToLast(const QString &thinking, const QString &signature = QString());
|
||||
|
||||
void clear();
|
||||
void resetTo(int index);
|
||||
|
||||
@@ -43,14 +43,14 @@ struct ErrorInfo
|
||||
return text.contains(QLatin1String(needle));
|
||||
};
|
||||
|
||||
if (contains("401") || contains("403") || contains("unauthorized")
|
||||
|| contains("forbidden") || contains("api key") || contains("apikey")
|
||||
|| contains("authentication") || contains("invalid token"))
|
||||
if (contains("401") || contains("403") || contains("unauthorized") || contains("forbidden")
|
||||
|| contains("api key") || contains("apikey") || contains("authentication")
|
||||
|| contains("invalid token"))
|
||||
return ErrorCategory::Auth;
|
||||
|
||||
if (contains("timeout") || contains("timed out") || contains("connection")
|
||||
|| contains("could not resolve") || contains("unreachable")
|
||||
|| contains("network") || contains("ssl") || contains("refused"))
|
||||
|| contains("could not resolve") || contains("unreachable") || contains("network")
|
||||
|| contains("ssl") || contains("refused"))
|
||||
return ErrorCategory::Network;
|
||||
|
||||
return ErrorCategory::Provider;
|
||||
|
||||
@@ -45,6 +45,16 @@ public:
|
||||
m_blocks.push_back(std::move(block));
|
||||
}
|
||||
|
||||
LLMQore::ContentBlock *lastBlock() noexcept
|
||||
{
|
||||
return m_blocks.empty() ? nullptr : m_blocks.back().get();
|
||||
}
|
||||
|
||||
std::vector<std::unique_ptr<LLMQore::ContentBlock>> takeBlocks() noexcept
|
||||
{
|
||||
return std::move(m_blocks);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
T *lastBlockOfType()
|
||||
{
|
||||
|
||||
@@ -165,7 +165,7 @@ std::unique_ptr<LLMQore::ContentBlock> blockFromJson(const QJsonObject &obj)
|
||||
FileEditContent::statusFromString(obj.value("status").toString()),
|
||||
obj.value("statusMessage").toString());
|
||||
}
|
||||
return nullptr; // unknown type — skipped
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -179,8 +179,11 @@ QJsonObject MessageSerializer::toJson(const Message &message)
|
||||
|
||||
QJsonArray blocks;
|
||||
for (const auto &b : message.blocks()) {
|
||||
if (b)
|
||||
blocks.append(blockToJson(*b));
|
||||
if (!b)
|
||||
continue;
|
||||
const QJsonObject blockObj = blockToJson(*b);
|
||||
if (!blockObj.isEmpty())
|
||||
blocks.append(blockObj);
|
||||
}
|
||||
obj["blocks"] = blocks;
|
||||
return obj;
|
||||
|
||||
@@ -144,8 +144,7 @@ public:
|
||||
ResponseEvents::Usage{inputTokens, outputTokens, cachedTokens, reasoningTokens}};
|
||||
}
|
||||
|
||||
static ResponseEvent error(
|
||||
QString message, ErrorCategory category = ErrorCategory::Provider)
|
||||
static ResponseEvent error(QString message, ErrorCategory category = ErrorCategory::Provider)
|
||||
{
|
||||
return {Kind::Error, ResponseEvents::Error{std::move(message), category}};
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ void ResponseRouter::onThinking(
|
||||
return;
|
||||
ensureAssistantOpen();
|
||||
if (m_history)
|
||||
m_history->appendThinkingDeltaToLast(thinking, signature);
|
||||
m_history->appendThinkingBlockToLast(thinking, signature);
|
||||
emit event(ResponseEvent::thinkingDelta(thinking, signature));
|
||||
}
|
||||
|
||||
@@ -153,11 +153,12 @@ void ResponseRouter::onFinalized(
|
||||
if (id != m_activeId)
|
||||
return;
|
||||
if (info.usage) {
|
||||
emit event(ResponseEvent::usage(
|
||||
info.usage->promptTokens,
|
||||
info.usage->completionTokens,
|
||||
info.usage->cachedPromptTokens,
|
||||
info.usage->reasoningTokens));
|
||||
emit event(
|
||||
ResponseEvent::usage(
|
||||
info.usage->promptTokens,
|
||||
info.usage->completionTokens,
|
||||
info.usage->cachedPromptTokens,
|
||||
info.usage->reasoningTokens));
|
||||
}
|
||||
emit event(ResponseEvent::messageStop(info.stopReason));
|
||||
endRequest();
|
||||
|
||||
@@ -28,13 +28,14 @@ namespace {
|
||||
} // namespace
|
||||
|
||||
Session::Session(Agent *agent, QObject *parent)
|
||||
: Session(agent, /*externalHistory=*/nullptr, parent)
|
||||
: Session(agent, nullptr, parent)
|
||||
{}
|
||||
|
||||
Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_history(externalHistory ? externalHistory : new ConversationHistory(this))
|
||||
, m_systemPrompt(new SystemPromptBuilder(this))
|
||||
, m_externalHistory(externalHistory != nullptr)
|
||||
{
|
||||
if (agent)
|
||||
setAgent(agent);
|
||||
@@ -45,15 +46,16 @@ void Session::setAgent(Agent *agent)
|
||||
if (agent == m_agent)
|
||||
return;
|
||||
|
||||
if (isInFlight())
|
||||
teardownInFlight();
|
||||
cancel();
|
||||
|
||||
if (m_router) {
|
||||
delete m_router;
|
||||
m_router->disconnect(this);
|
||||
m_router->deleteLater();
|
||||
m_router = nullptr;
|
||||
}
|
||||
|
||||
delete m_agent;
|
||||
if (m_agent)
|
||||
m_agent->deleteLater();
|
||||
m_agent = agent;
|
||||
m_invalidReason.clear();
|
||||
|
||||
@@ -84,8 +86,7 @@ void Session::setAgent(Agent *agent)
|
||||
|
||||
Session::~Session()
|
||||
{
|
||||
if (isInFlight())
|
||||
teardownInFlight();
|
||||
cancel();
|
||||
}
|
||||
|
||||
bool Session::isValid() const noexcept
|
||||
@@ -154,6 +155,11 @@ void Session::unpinContext(const QString &id)
|
||||
std::erase_if(m_pinnedProviders, [&id](const auto &entry) { return entry.first == id; });
|
||||
}
|
||||
|
||||
void Session::clearPinnedContext()
|
||||
{
|
||||
m_pinnedProviders.clear();
|
||||
}
|
||||
|
||||
LLMQore::RequestID Session::send(std::vector<std::unique_ptr<LLMQore::ContentBlock>> userBlocks)
|
||||
{
|
||||
if (!canSend()) {
|
||||
@@ -165,19 +171,24 @@ LLMQore::RequestID Session::send(std::vector<std::unique_ptr<LLMQore::ContentBlo
|
||||
return {};
|
||||
}
|
||||
if (userBlocks.empty() || !m_history) {
|
||||
m_lastError = makeError(ErrorCategory::Validation, QStringLiteral("Session: nothing to send"));
|
||||
m_lastError
|
||||
= makeError(ErrorCategory::Validation, QStringLiteral("Session: nothing to send"));
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isInFlight())
|
||||
cancel();
|
||||
|
||||
const int preSendSize = m_history->size();
|
||||
Message msg(Message::Role::User);
|
||||
for (auto &b : userBlocks)
|
||||
msg.appendBlock(std::move(b));
|
||||
m_history->append(std::move(msg));
|
||||
|
||||
return dispatch();
|
||||
const auto id = dispatch();
|
||||
if (id.isEmpty())
|
||||
m_history->resetTo(preSendSize);
|
||||
return id;
|
||||
}
|
||||
|
||||
QVector<ContextAssembler::PinnedBlock> Session::materializePinned() const
|
||||
@@ -223,8 +234,8 @@ LLMQore::RequestID Session::dispatch()
|
||||
m_systemPrompt->clearLayer(QStringLiteral("agent.system"));
|
||||
} else {
|
||||
QString renderErr;
|
||||
const QString renderedContext = Templates::ContextRenderer::render(
|
||||
cfg.systemPrompt, m_contextBindings, &renderErr);
|
||||
const QString renderedContext
|
||||
= Templates::ContextRenderer::render(cfg.systemPrompt, m_contextBindings, &renderErr);
|
||||
if (!renderErr.isEmpty()) {
|
||||
m_lastError = makeError(
|
||||
ErrorCategory::Validation,
|
||||
@@ -237,7 +248,9 @@ LLMQore::RequestID Session::dispatch()
|
||||
m_systemPrompt->clearLayer(QStringLiteral("agent.system"));
|
||||
else
|
||||
m_systemPrompt->setLayer(
|
||||
QStringLiteral("agent.system"), renderedContext, SystemPromptBuilder::kAgentPriority);
|
||||
QStringLiteral("agent.system"),
|
||||
renderedContext,
|
||||
SystemPromptBuilder::kAgentPriority);
|
||||
}
|
||||
|
||||
return dispatchContext(assembleContext(), cfg.enableTools);
|
||||
@@ -259,13 +272,37 @@ LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, b
|
||||
|
||||
QString endpoint = cfg.endpoint;
|
||||
endpoint.replace(QStringLiteral("${MODEL}"), cfg.model);
|
||||
|
||||
LLMQore::RequestID earlyFailId;
|
||||
QString earlyFailError;
|
||||
QMetaObject::Connection earlyFailConn;
|
||||
if (auto *cl = client()) {
|
||||
earlyFailConn = connect(
|
||||
cl,
|
||||
&LLMQore::BaseClient::requestFailed,
|
||||
this,
|
||||
[&earlyFailId, &earlyFailError](const LLMQore::RequestID &fid, const QString &err) {
|
||||
earlyFailId = fid;
|
||||
earlyFailError = err;
|
||||
},
|
||||
Qt::DirectConnection);
|
||||
}
|
||||
|
||||
const auto id = provider->sendRequest(QUrl(provider->url()), payload, endpoint);
|
||||
|
||||
if (earlyFailConn)
|
||||
disconnect(earlyFailConn);
|
||||
|
||||
if (id.isEmpty()) {
|
||||
m_lastError = makeError(
|
||||
ErrorCategory::Provider,
|
||||
QStringLiteral("Provider '%1' failed to start the request").arg(provider->name()));
|
||||
return {};
|
||||
}
|
||||
if (id == earlyFailId && !earlyFailError.isEmpty()) {
|
||||
m_lastError = makeError(categorizeProviderError(earlyFailError), earlyFailError);
|
||||
return {};
|
||||
}
|
||||
|
||||
m_inFlight = id;
|
||||
if (m_router)
|
||||
@@ -274,8 +311,7 @@ LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, b
|
||||
return id;
|
||||
}
|
||||
|
||||
QJsonObject Session::buildPayload(
|
||||
const Templates::ContextData &ctx, bool tools, QString *errOut) const
|
||||
QJsonObject Session::buildPayload(const Templates::ContextData &ctx, bool tools, QString *errOut) const
|
||||
{
|
||||
auto *provider = m_agent->provider();
|
||||
auto *tmpl = m_agent->promptTemplate();
|
||||
@@ -302,7 +338,7 @@ Templates::ContextData Session::assembleContext() const
|
||||
void Session::onRouterEvent(const ResponseEvent &ev)
|
||||
{
|
||||
if (m_inFlight.isEmpty())
|
||||
return; // stale events after cancel
|
||||
return;
|
||||
|
||||
emit event(ev);
|
||||
|
||||
|
||||
@@ -56,10 +56,12 @@ public:
|
||||
using PinnedProvider = std::function<QString()>;
|
||||
void pinContext(const QString &id, PinnedProvider provider);
|
||||
void unpinContext(const QString &id);
|
||||
void clearPinnedContext();
|
||||
|
||||
Agent *agent() noexcept { return m_agent; }
|
||||
void setAgent(Agent *agent);
|
||||
ConversationHistory *history() const noexcept { return m_history; }
|
||||
bool usesExternalHistory() const noexcept { return m_externalHistory; }
|
||||
SystemPromptBuilder *systemPrompt() const noexcept { return m_systemPrompt; }
|
||||
|
||||
LLMQore::BaseClient *client() const noexcept;
|
||||
@@ -89,10 +91,11 @@ private:
|
||||
QVector<ContextAssembler::PinnedBlock> materializePinned() const;
|
||||
QJsonObject buildPayload(const Templates::ContextData &ctx, bool tools, QString *errOut) const;
|
||||
|
||||
Agent *m_agent = nullptr; // child if non-null
|
||||
QPointer<ConversationHistory> m_history; // child if internal, external otherwise
|
||||
SystemPromptBuilder *m_systemPrompt = nullptr; // child
|
||||
ResponseRouter *m_router = nullptr; // child, only when valid
|
||||
Agent *m_agent = nullptr;
|
||||
QPointer<ConversationHistory> m_history;
|
||||
SystemPromptBuilder *m_systemPrompt = nullptr;
|
||||
ResponseRouter *m_router = nullptr;
|
||||
bool m_externalHistory = false;
|
||||
|
||||
LLMQore::RequestID m_inFlight;
|
||||
QString m_invalidReason;
|
||||
|
||||
@@ -27,7 +27,7 @@ SessionManager::~SessionManager() = default;
|
||||
|
||||
Session *SessionManager::createSession(const QString &agentName, QString *errorOut)
|
||||
{
|
||||
return createSession(agentName, /*externalHistory=*/nullptr, errorOut);
|
||||
return createSession(agentName, nullptr, errorOut);
|
||||
}
|
||||
|
||||
Session *SessionManager::createSession(
|
||||
@@ -40,7 +40,7 @@ Session *SessionManager::createSession(
|
||||
}
|
||||
|
||||
QString agentErr;
|
||||
Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr);
|
||||
Agent *agent = m_agentFactory->create(agentName, nullptr, &agentErr);
|
||||
if (!agent) {
|
||||
if (errorOut)
|
||||
*errorOut = agentErr.isEmpty()
|
||||
@@ -53,7 +53,7 @@ Session *SessionManager::createSession(
|
||||
if (!session->isValid()) {
|
||||
if (errorOut)
|
||||
*errorOut = session->invalidReason();
|
||||
delete session; // also deletes the reparented agent
|
||||
delete session;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ Session *SessionManager::createSession(
|
||||
|
||||
Session *SessionManager::createDetachedSession(ConversationHistory *externalHistory, QObject *parent)
|
||||
{
|
||||
return new Session(/*agent=*/nullptr, externalHistory, parent);
|
||||
return new Session(nullptr, externalHistory, parent);
|
||||
}
|
||||
|
||||
bool SessionManager::rebindAgentByName(Session *session, const QString &agentName, QString *errorOut)
|
||||
@@ -81,7 +81,7 @@ bool SessionManager::rebindAgentByName(Session *session, const QString &agentNam
|
||||
}
|
||||
|
||||
QString agentErr;
|
||||
Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr);
|
||||
Agent *agent = m_agentFactory->create(agentName, nullptr, &agentErr);
|
||||
if (!agent) {
|
||||
if (errorOut)
|
||||
*errorOut = agentErr.isEmpty()
|
||||
@@ -113,7 +113,7 @@ Session *SessionManager::acquire(const QString &agentName, QString *errorOut)
|
||||
pooled->deleteLater();
|
||||
}
|
||||
|
||||
return createSession(agentName, /*externalHistory=*/nullptr, errorOut);
|
||||
return createSession(agentName, nullptr, errorOut);
|
||||
}
|
||||
|
||||
void SessionManager::release(Session *session)
|
||||
@@ -130,10 +130,16 @@ void SessionManager::release(Session *session)
|
||||
session->cancel();
|
||||
|
||||
session->disconnect();
|
||||
|
||||
if (session->usesExternalHistory()) {
|
||||
emit sessionRemoved(session);
|
||||
session->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
resetSession(session);
|
||||
|
||||
const QString agentName
|
||||
= session->agent() ? session->agent()->config().name : QString();
|
||||
const QString agentName = session->agent() ? session->agent()->config().name : QString();
|
||||
QList<QPointer<Session>> &bucket = m_pool[agentName];
|
||||
if (agentName.isEmpty() || bucket.size() >= kMaxPooledPerAgent) {
|
||||
emit sessionRemoved(session);
|
||||
@@ -155,6 +161,9 @@ void SessionManager::resetSession(Session *session)
|
||||
if (auto *tools = client->tools())
|
||||
tools->removeAllTools();
|
||||
}
|
||||
session->setContentLoader({});
|
||||
session->setContextBindings({});
|
||||
session->clearPinnedContext();
|
||||
}
|
||||
|
||||
bool SessionManager::pooledAgentMatchesCurrent(Session *session, const QString &agentName) const
|
||||
|
||||
@@ -53,8 +53,7 @@ signals:
|
||||
private:
|
||||
void resetSession(Session *session);
|
||||
void flushPool();
|
||||
[[nodiscard]] bool pooledAgentMatchesCurrent(
|
||||
Session *session, const QString &agentName) const;
|
||||
[[nodiscard]] bool pooledAgentMatchesCurrent(Session *session, const QString &agentName) const;
|
||||
|
||||
static constexpr int kMaxPooledPerAgent = 2;
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ void SystemPromptBuilder::clear()
|
||||
QString SystemPromptBuilder::layer(const QString &name) const
|
||||
{
|
||||
for (const auto &l : m_layers) {
|
||||
if (l.name == name) return l.text;
|
||||
if (l.name == name)
|
||||
return l.text;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -58,16 +59,17 @@ QStringList SystemPromptBuilder::layerNames() const
|
||||
{
|
||||
QStringList out;
|
||||
out.reserve(m_layers.size());
|
||||
for (const auto &l : m_layers) out.append(l.name);
|
||||
for (const auto &l : m_layers)
|
||||
out.append(l.name);
|
||||
return out;
|
||||
}
|
||||
|
||||
QString SystemPromptBuilder::compose(const QString &separator) const
|
||||
{
|
||||
QVector<Layer> ordered = m_layers;
|
||||
std::stable_sort(
|
||||
ordered.begin(), ordered.end(),
|
||||
[](const Layer &a, const Layer &b) { return a.priority < b.priority; });
|
||||
std::stable_sort(ordered.begin(), ordered.end(), [](const Layer &a, const Layer &b) {
|
||||
return a.priority < b.priority;
|
||||
});
|
||||
|
||||
QStringList parts;
|
||||
parts.reserve(ordered.size());
|
||||
|
||||
@@ -56,7 +56,8 @@ Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *pa
|
||||
}
|
||||
m_provider->setParent(this);
|
||||
m_provider->setPromptCaching(
|
||||
m_config.cachePrompt, m_config.cacheTtl == QLatin1StringView{"1h"},
|
||||
m_config.cachePrompt,
|
||||
m_config.cacheTtl == QLatin1StringView{"1h"},
|
||||
m_config.cacheBreakpoints);
|
||||
|
||||
QString tmplErr;
|
||||
|
||||
@@ -46,8 +46,8 @@ public:
|
||||
|
||||
private:
|
||||
AgentConfig m_config;
|
||||
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate; // owned
|
||||
Providers::Provider *m_provider = nullptr; // child of this
|
||||
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate;
|
||||
Providers::Provider *m_provider = nullptr;
|
||||
QString m_invalidReason;
|
||||
};
|
||||
|
||||
|
||||
@@ -124,8 +124,8 @@ AgentConfig configFromMerged(const QJsonObject &obj)
|
||||
cfg.systemPrompt = obj.value("system_prompt").toString();
|
||||
cfg.enableThinking = obj.value("enable_thinking").toBool(false);
|
||||
cfg.enableTools = obj.value("enable_tools").toBool(false);
|
||||
cfg.cachePrompt = obj.value("cache_prompt").toBool(false);
|
||||
cfg.cacheTtl = obj.value("cache_ttl").toString();
|
||||
cfg.cachePrompt = obj.value("cache_prompt").toBool(false);
|
||||
cfg.cacheTtl = obj.value("cache_ttl").toString();
|
||||
cfg.cacheBreakpoints = stringArray(obj.value("cache_breakpoints"));
|
||||
cfg.tags = stringArray(obj.value("tags"));
|
||||
|
||||
@@ -153,26 +153,34 @@ constexpr int kMaxExtendsDepth = 32;
|
||||
|
||||
void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringList &warnings)
|
||||
{
|
||||
static const QSet<QString> kTopLevelKeys = {
|
||||
QStringLiteral("schema_version"), QStringLiteral("name"),
|
||||
QStringLiteral("description"), QStringLiteral("provider_instance"),
|
||||
QStringLiteral("model"), QStringLiteral("endpoint"),
|
||||
QStringLiteral("system_prompt"), QStringLiteral("tags"),
|
||||
QStringLiteral("match"), QStringLiteral("enable_thinking"),
|
||||
QStringLiteral("enable_tools"), QStringLiteral("cache_prompt"),
|
||||
QStringLiteral("cache_ttl"), QStringLiteral("cache_breakpoints"),
|
||||
QStringLiteral("body"),
|
||||
QStringLiteral("extends"), QStringLiteral("abstract"),
|
||||
QStringLiteral("hidden")};
|
||||
static const QSet<QString> kMatchKeys = {
|
||||
QStringLiteral("file_patterns"),
|
||||
QStringLiteral("path_patterns"),
|
||||
QStringLiteral("project_names")};
|
||||
static const QSet<QString> kTopLevelKeys
|
||||
= {QStringLiteral("schema_version"),
|
||||
QStringLiteral("name"),
|
||||
QStringLiteral("description"),
|
||||
QStringLiteral("provider_instance"),
|
||||
QStringLiteral("model"),
|
||||
QStringLiteral("endpoint"),
|
||||
QStringLiteral("system_prompt"),
|
||||
QStringLiteral("tags"),
|
||||
QStringLiteral("match"),
|
||||
QStringLiteral("enable_thinking"),
|
||||
QStringLiteral("enable_tools"),
|
||||
QStringLiteral("cache_prompt"),
|
||||
QStringLiteral("cache_ttl"),
|
||||
QStringLiteral("cache_breakpoints"),
|
||||
QStringLiteral("body"),
|
||||
QStringLiteral("extends"),
|
||||
QStringLiteral("abstract"),
|
||||
QStringLiteral("hidden")};
|
||||
static const QSet<QString> kMatchKeys
|
||||
= {QStringLiteral("file_patterns"),
|
||||
QStringLiteral("path_patterns"),
|
||||
QStringLiteral("project_names")};
|
||||
|
||||
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
|
||||
if (!kTopLevelKeys.contains(it.key())) {
|
||||
warnings.append(QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)")
|
||||
.arg(it.key(), filePath));
|
||||
warnings.append(
|
||||
QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)").arg(it.key(), filePath));
|
||||
}
|
||||
}
|
||||
const QJsonObject matchObj = obj.value("match").toObject();
|
||||
@@ -183,12 +191,13 @@ void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringLis
|
||||
}
|
||||
}
|
||||
|
||||
static const QSet<QString> kCacheBreakpoints = {
|
||||
QStringLiteral("system"), QStringLiteral("tools"), QStringLiteral("history")};
|
||||
static const QSet<QString> kCacheBreakpoints
|
||||
= {QStringLiteral("system"), QStringLiteral("tools"), QStringLiteral("history")};
|
||||
for (const QJsonValue &bp : obj.value("cache_breakpoints").toArray()) {
|
||||
if (bp.isString() && !kCacheBreakpoints.contains(bp.toString())) {
|
||||
warnings.append(QStringLiteral("Unknown cache_breakpoint '%1' in %2 — ignored "
|
||||
"(use system/tools/history)")
|
||||
warnings.append(QStringLiteral(
|
||||
"Unknown cache_breakpoint '%1' in %2 — ignored "
|
||||
"(use system/tools/history)")
|
||||
.arg(bp.toString(), filePath));
|
||||
}
|
||||
}
|
||||
@@ -201,10 +210,12 @@ void scanDir(
|
||||
QStringList &errors,
|
||||
QStringList *warnings)
|
||||
{
|
||||
if (dir.isEmpty()) return;
|
||||
if (dir.isEmpty())
|
||||
return;
|
||||
QDir d(dir);
|
||||
if (!d.exists()) return;
|
||||
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
|
||||
if (!d.exists())
|
||||
return;
|
||||
const QStringList files = d.entryList({"*.toml"}, QDir::Files, QDir::Name);
|
||||
for (const QString &fname : files) {
|
||||
const QString fullPath = d.filePath(fname);
|
||||
QString err;
|
||||
@@ -222,17 +233,16 @@ void scanDir(
|
||||
lintUnknownKeys(*objOpt, fullPath, *warnings);
|
||||
const auto existing = raw.constFind(name);
|
||||
if (existing != raw.constEnd() && existing->isUserLayer != isUserLayer) {
|
||||
errors.append(
|
||||
QStringLiteral("Agent '%1' at %2 has the same name as a bundled agent — "
|
||||
"bundled agents cannot be replaced; rename it and use "
|
||||
"'extends' to build on the bundled one")
|
||||
.arg(name, fullPath));
|
||||
errors.append(QStringLiteral(
|
||||
"Agent '%1' at %2 has the same name as a bundled agent — "
|
||||
"bundled agents cannot be replaced; rename it and use "
|
||||
"'extends' to build on the bundled one")
|
||||
.arg(name, fullPath));
|
||||
continue;
|
||||
}
|
||||
if (warnings && existing != raw.constEnd()) {
|
||||
warnings->append(
|
||||
QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins")
|
||||
.arg(name, existing->filePath, fullPath));
|
||||
warnings->append(QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins")
|
||||
.arg(name, existing->filePath, fullPath));
|
||||
}
|
||||
raw.insert(name, {*objOpt, fullPath, isUserLayer});
|
||||
}
|
||||
@@ -251,7 +261,7 @@ QJsonObject mergeChild(const QJsonObject &parentMerged, const QJsonObject &self,
|
||||
return merged;
|
||||
}
|
||||
|
||||
QJsonObject resolveExtends(
|
||||
std::optional<QJsonObject> resolveExtends(
|
||||
const QString &name,
|
||||
const QHash<QString, RawEntry> &raw,
|
||||
QSet<QString> &visiting,
|
||||
@@ -262,15 +272,15 @@ QJsonObject resolveExtends(
|
||||
errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'")
|
||||
.arg(kMaxExtendsDepth)
|
||||
.arg(name));
|
||||
return {};
|
||||
return std::nullopt;
|
||||
}
|
||||
if (visiting.contains(name)) {
|
||||
errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name));
|
||||
return {};
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!raw.contains(name)) {
|
||||
errors.append(QStringLiteral("Unknown agent '%1'").arg(name));
|
||||
return {};
|
||||
return std::nullopt;
|
||||
}
|
||||
visiting.insert(name);
|
||||
|
||||
@@ -281,11 +291,14 @@ QJsonObject resolveExtends(
|
||||
errors.append(QStringLiteral("Agent '%1' extends unknown agent '%2' (%3)")
|
||||
.arg(name, parent, raw.value(name).filePath));
|
||||
visiting.remove(name);
|
||||
return {};
|
||||
return std::nullopt;
|
||||
}
|
||||
const QJsonObject parentMerged
|
||||
= resolveExtends(parent, raw, visiting, errors, depth + 1);
|
||||
self = mergeChild(parentMerged, self, name);
|
||||
const auto parentMerged = resolveExtends(parent, raw, visiting, errors, depth + 1);
|
||||
if (!parentMerged) {
|
||||
visiting.remove(name);
|
||||
return std::nullopt;
|
||||
}
|
||||
self = mergeChild(*parentMerged, self, name);
|
||||
}
|
||||
visiting.remove(name);
|
||||
return self;
|
||||
@@ -294,17 +307,15 @@ QJsonObject resolveExtends(
|
||||
} // namespace
|
||||
|
||||
std::optional<AgentConfig> AgentLoader::parseFile(
|
||||
const QString &path,
|
||||
const QString &qrcPrefix,
|
||||
QString *error,
|
||||
QStringList *warnings)
|
||||
const QString &path, const QString &qrcPrefix, QString *error, QStringList *warnings)
|
||||
{
|
||||
auto objOpt = parseTomlFile(path, error);
|
||||
if (!objOpt) return std::nullopt;
|
||||
|
||||
const QString name = objOpt->value("name").toString();
|
||||
if (name.isEmpty()) {
|
||||
if (error) *error = QStringLiteral("Agent at %1 has no 'name'").arg(path);
|
||||
if (error)
|
||||
*error = QStringLiteral("Agent at %1 has no 'name'").arg(path);
|
||||
return std::nullopt;
|
||||
}
|
||||
if (warnings)
|
||||
@@ -312,28 +323,40 @@ std::optional<AgentConfig> AgentLoader::parseFile(
|
||||
|
||||
QHash<QString, RawEntry> raw;
|
||||
QStringList scanErrors;
|
||||
scanDir(qrcPrefix, /*isUserLayer=*/false, raw, scanErrors, nullptr);
|
||||
scanDir(QFileInfo(path).absolutePath(), /*isUserLayer=*/true, raw, scanErrors, nullptr);
|
||||
raw.insert(name, {*objOpt, path, true});
|
||||
scanDir(qrcPrefix, false, raw, scanErrors, nullptr);
|
||||
|
||||
QSet<QString> visiting;
|
||||
QStringList resolveErrors;
|
||||
const QJsonObject merged = resolveExtends(name, raw, visiting, resolveErrors);
|
||||
if (!resolveErrors.isEmpty() || merged.isEmpty()) {
|
||||
const auto bundled = raw.constFind(name);
|
||||
if (bundled != raw.constEnd() && !bundled->isUserLayer) {
|
||||
if (error) {
|
||||
*error = resolveErrors.isEmpty()
|
||||
? QStringLiteral("Agent '%1' resolved to an empty config").arg(name)
|
||||
: resolveErrors.join(QStringLiteral("; "));
|
||||
*error = QStringLiteral(
|
||||
"Agent '%1' at %2 has the same name as a bundled agent — bundled "
|
||||
"agents cannot be replaced; rename it and use 'extends' to build "
|
||||
"on the bundled one")
|
||||
.arg(name, path);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
AgentConfig cfg = configFromMerged(merged);
|
||||
scanDir(QFileInfo(path).absolutePath(), true, raw, scanErrors, nullptr);
|
||||
raw.insert(name, {*objOpt, path, true});
|
||||
|
||||
QSet<QString> visiting;
|
||||
QStringList resolveErrors;
|
||||
const auto merged = resolveExtends(name, raw, visiting, resolveErrors);
|
||||
if (!merged) {
|
||||
if (error)
|
||||
*error = resolveErrors.join(QStringLiteral("; "));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
AgentConfig cfg = configFromMerged(*merged);
|
||||
cfg.sourcePath = path;
|
||||
if (cfg.abstract) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Agent '%1' is abstract — extend it instead of "
|
||||
"loading it directly").arg(name);
|
||||
*error = QStringLiteral(
|
||||
"Agent '%1' is abstract — extend it instead of "
|
||||
"loading it directly")
|
||||
.arg(name);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
@@ -345,8 +368,8 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin
|
||||
LoadResult result;
|
||||
QHash<QString, RawEntry> raw;
|
||||
|
||||
scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors, &result.warnings);
|
||||
scanDir(userDir, /*isUserLayer=*/true, raw, result.errors, &result.warnings);
|
||||
scanDir(qrcPrefix, false, raw, result.errors, &result.warnings);
|
||||
scanDir(userDir, true, raw, result.errors, &result.warnings);
|
||||
|
||||
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it)
|
||||
result.sourcePathByName.insert(it.key(), it.value().filePath);
|
||||
@@ -355,10 +378,11 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin
|
||||
const QString &name = it.key();
|
||||
|
||||
QSet<QString> visiting;
|
||||
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
|
||||
if (merged.isEmpty()) continue;
|
||||
const auto merged = resolveExtends(name, raw, visiting, result.errors);
|
||||
if (!merged)
|
||||
continue;
|
||||
|
||||
AgentConfig cfg = configFromMerged(merged);
|
||||
AgentConfig cfg = configFromMerged(*merged);
|
||||
cfg.sourcePath = it.value().filePath;
|
||||
|
||||
if (cfg.abstract) continue;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <vector>
|
||||
|
||||
#include "AgentConfig.hpp"
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ QRegularExpression compiledGlob(const QString &pattern)
|
||||
const auto it = cache.constFind(pattern);
|
||||
if (it != cache.constEnd())
|
||||
return *it;
|
||||
const QRegularExpression re(
|
||||
QRegularExpression::anchoredPattern(
|
||||
QRegularExpression::wildcardToRegularExpression(
|
||||
pattern, QRegularExpression::NonPathWildcardConversion)),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
const QRegularExpression
|
||||
re(QRegularExpression::anchoredPattern(
|
||||
QRegularExpression::wildcardToRegularExpression(
|
||||
pattern, QRegularExpression::NonPathWildcardConversion)),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
cache.insert(pattern, re);
|
||||
return re;
|
||||
}
|
||||
@@ -67,11 +67,9 @@ bool matchesPathPatterns(const QStringList &patterns, const QString &filePath)
|
||||
bool matchesProjectNames(const QStringList &names, const QString &projectName)
|
||||
{
|
||||
if (names.isEmpty())
|
||||
return true; // dimension unconstrained
|
||||
return true;
|
||||
if (projectName.isEmpty())
|
||||
return false;
|
||||
// Project names are user-facing identifiers, not paths — case
|
||||
// sensitive comparison matches what ProjectExplorer hands us.
|
||||
return names.contains(projectName);
|
||||
}
|
||||
|
||||
@@ -80,7 +78,7 @@ bool matchesProjectNames(const QStringList &names, const QString &projectName)
|
||||
bool matches(const AgentConfig::Match &m, const Context &ctx)
|
||||
{
|
||||
if (m.isEmpty())
|
||||
return true; // explicit catch-all
|
||||
return true;
|
||||
return matchesFilePatterns(m.filePatterns, ctx.filePath)
|
||||
&& matchesPathPatterns(m.pathPatterns, ctx.filePath)
|
||||
&& matchesProjectNames(m.projectNames, ctx.projectName);
|
||||
@@ -92,7 +90,7 @@ QString pickAgent(
|
||||
for (const QString &name : roster) {
|
||||
const AgentConfig *cfg = factory.configByName(name);
|
||||
if (!cfg)
|
||||
continue; // stale roster entry — silently skip
|
||||
continue;
|
||||
if (matches(cfg->match, ctx))
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -61,11 +61,18 @@ QString expandAndResolvePath(const QString &raw, const Bindings &b)
|
||||
return p;
|
||||
}
|
||||
|
||||
bool hasUnresolvedVars(const QString &p)
|
||||
{
|
||||
return p.contains(QStringLiteral("${PROJECT_DIR}"))
|
||||
|| p.contains(QStringLiteral("${CONFIG_DIR}"));
|
||||
}
|
||||
|
||||
[[noreturn]] void throwOutsideRoots(const char *fn, const QString &path)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
QStringLiteral("%1: path is outside the allowed read roots "
|
||||
"(the project directory, ~/qodeassist, or bundled :/ resources): %2")
|
||||
QStringLiteral(
|
||||
"%1: path is outside the allowed read roots "
|
||||
"(the project directory, ~/qodeassist, or bundled :/ resources): %2")
|
||||
.arg(QString::fromLatin1(fn), path)
|
||||
.toStdString());
|
||||
}
|
||||
@@ -77,6 +84,13 @@ void registerReadFile(inja::Environment &env, const Bindings &b)
|
||||
const QString path
|
||||
= expandAndResolvePath(QString::fromStdString(args.at(0)->get<std::string>()), caps);
|
||||
|
||||
if (hasUnresolvedVars(path)) {
|
||||
throw std::runtime_error(QStringLiteral(
|
||||
"read_file: ${PROJECT_DIR}/${CONFIG_DIR} is not available "
|
||||
"(no project open?): %1")
|
||||
.arg(path)
|
||||
.toStdString());
|
||||
}
|
||||
if (!isPathAllowed(path, caps))
|
||||
throwOutsideRoots("read_file", path);
|
||||
|
||||
@@ -97,6 +111,8 @@ void registerFileExists(inja::Environment &env, const Bindings &b)
|
||||
env.add_callback("file_exists", 1, [caps](inja::Arguments &args) -> nlohmann::json {
|
||||
const QString p
|
||||
= expandAndResolvePath(QString::fromStdString(args.at(0)->get<std::string>()), caps);
|
||||
if (hasUnresolvedVars(p))
|
||||
return false;
|
||||
if (!isPathAllowed(p, caps))
|
||||
throwOutsideRoots("file_exists", p);
|
||||
return QFileInfo::exists(p);
|
||||
@@ -110,6 +126,8 @@ void registerReadDir(inja::Environment &env, const Bindings &b)
|
||||
env.add_callback("read_dir", 1, [caps](inja::Arguments &args) -> nlohmann::json {
|
||||
const QString p
|
||||
= expandAndResolvePath(QString::fromStdString(args.at(0)->get<std::string>()), caps);
|
||||
if (hasUnresolvedVars(p))
|
||||
return nlohmann::json::array();
|
||||
if (!isPathAllowed(p, caps))
|
||||
throwOutsideRoots("read_dir", p);
|
||||
QDir dir(p);
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<file>openai_completion.toml</file>
|
||||
<file>openai_compression.toml</file>
|
||||
<file>openai_quick_refactor.toml</file>
|
||||
<file>deepseek_chat.toml</file>
|
||||
<file>qwen_chat.toml</file>
|
||||
<file>google_base_chat.toml</file>
|
||||
<file>google_chat.toml</file>
|
||||
<file>google_completion.toml</file>
|
||||
|
||||
16
sources/agents/deepseek_chat.toml
Normal file
16
sources/agents/deepseek_chat.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
schema_version = 1
|
||||
|
||||
extends = "OpenAI Base Chat"
|
||||
name = "DeepSeek Chat"
|
||||
description = "DeepSeek coding chat (deepseek-chat, V4 family) over the OpenAI-compatible Chat Completions API — conversational assistant with IDE tool use."
|
||||
|
||||
provider_instance = "DeepSeek"
|
||||
model = "deepseek-chat"
|
||||
enable_tools = true
|
||||
tags = ["chat", "deepseek", "cloud"]
|
||||
|
||||
system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
|
||||
|
||||
[body]
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
@@ -12,11 +12,11 @@ tags = ["chat", "ollama", "local", "16gb"]
|
||||
system_prompt = """{{ read_file(":/roles/agentic-coder.md") }}"""
|
||||
|
||||
[body]
|
||||
think = true
|
||||
think = true
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
temperature = 1.0
|
||||
top_p = 0.95
|
||||
top_k = 64
|
||||
num_predict = 8096
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -2,14 +2,16 @@ schema_version = 1
|
||||
|
||||
extends = "Ollama Base Chat"
|
||||
name = "Ollama Chat — Simple"
|
||||
description = "Local Ollama coding chat for any model — plain conversational assistant, no tools, no thinking (Qwen2.5-Coder 7B by default)."
|
||||
description = "Local Ollama coding chat for any model — plain conversational assistant, no tools, no thinking (Qwen3.5 4B by default)."
|
||||
|
||||
model = "qwen3.5:4b"
|
||||
tags = ["chat", "ollama", "local", "8gb"]
|
||||
|
||||
system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
|
||||
|
||||
[body]
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 2048
|
||||
temperature = 0.7
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -12,8 +12,8 @@ tags = ["chat", "ollama", "local", "16gb"]
|
||||
system_prompt = """{{ read_file(":/roles/agentic-coder.md") }}"""
|
||||
|
||||
[body]
|
||||
think = true
|
||||
think = true
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 8192
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -15,6 +15,7 @@ system_prompt = """
|
||||
{{ read_file(":/tasks/code-completion.md") }}"""
|
||||
|
||||
[body]
|
||||
keep_alive = "5m"
|
||||
messages = """
|
||||
[
|
||||
{% if existsIn(ctx, "system_prompt") %}
|
||||
@@ -27,5 +28,4 @@ messages = """
|
||||
[body.options]
|
||||
num_predict = 256
|
||||
temperature = 0.2
|
||||
keep_alive = "5m"
|
||||
stop = ["</code_context>"]
|
||||
|
||||
@@ -7,7 +7,9 @@ description = "Native fill-in-the-middle completion — uses the model's OWN FIM
|
||||
model = "qwen2.5-coder:7b-base-q5_K_M"
|
||||
tags = ["completion", "ollama", "local", "fim", "8gb"]
|
||||
|
||||
[body]
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 256
|
||||
temperature = 0
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "16gb"]
|
||||
system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}"""
|
||||
|
||||
[body]
|
||||
think = false
|
||||
think = false
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 2048
|
||||
temperature = 0.3
|
||||
num_ctx = 8192
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "32gb"]
|
||||
system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}"""
|
||||
|
||||
[body]
|
||||
think = false
|
||||
think = false
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 2048
|
||||
temperature = 0.3
|
||||
num_ctx = 24576
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "8gb"]
|
||||
system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}"""
|
||||
|
||||
[body]
|
||||
think = false
|
||||
think = false
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 2048
|
||||
temperature = 0.3
|
||||
num_ctx = 8192
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -12,8 +12,8 @@ tags = ["refactor", "ollama", "local", "16gb"]
|
||||
system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}"""
|
||||
|
||||
[body]
|
||||
think = true
|
||||
think = true
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 4096
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -12,8 +12,8 @@ tags = ["refactor", "ollama", "local", "16gb"]
|
||||
system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}"""
|
||||
|
||||
[body]
|
||||
think = true
|
||||
think = true
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 4096
|
||||
keep_alive = "5m"
|
||||
|
||||
@@ -10,7 +10,9 @@ tags = ["refactor", "ollama", "local", "8gb"]
|
||||
|
||||
system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}"""
|
||||
|
||||
[body]
|
||||
keep_alive = "5m"
|
||||
|
||||
[body.options]
|
||||
num_predict = 2048
|
||||
temperature = 0.2
|
||||
keep_alive = "5m"
|
||||
|
||||
16
sources/agents/qwen_chat.toml
Normal file
16
sources/agents/qwen_chat.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
schema_version = 1
|
||||
|
||||
extends = "OpenAI Base Chat"
|
||||
name = "Qwen Chat"
|
||||
description = "Alibaba Qwen coding chat (qwen-plus stable alias, currently the Qwen3.6 Plus family) over the DashScope OpenAI-compatible Chat Completions API — conversational assistant with IDE tool use."
|
||||
|
||||
provider_instance = "Qwen"
|
||||
model = "qwen-plus"
|
||||
enable_tools = true
|
||||
tags = ["chat", "qwen", "cloud"]
|
||||
|
||||
system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
|
||||
|
||||
[body]
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
@@ -24,15 +24,15 @@ struct ContentBlockEntry
|
||||
|
||||
Kind kind = Kind::Text;
|
||||
|
||||
QString text; // Text
|
||||
QString thinking; // Thinking
|
||||
QString signature; // Thinking / RedactedThinking
|
||||
QString toolUseId; // ToolUse / ToolResult
|
||||
QString toolName; // ToolUse
|
||||
QJsonObject toolInput; // ToolUse
|
||||
QString result; // ToolResult
|
||||
QString imageData; // Image (base64 or url)
|
||||
QString mediaType; // Image
|
||||
QString text;
|
||||
QString thinking;
|
||||
QString signature;
|
||||
QString toolUseId;
|
||||
QString toolName;
|
||||
QJsonObject toolInput;
|
||||
QString result;
|
||||
QString imageData;
|
||||
QString mediaType;
|
||||
bool isImageUrl = false;
|
||||
|
||||
bool operator==(const ContentBlockEntry &) const = default;
|
||||
@@ -43,7 +43,6 @@ struct Message
|
||||
QString role;
|
||||
QVector<ContentBlockEntry> blocks;
|
||||
|
||||
// Convenience for callers that only need a single text block.
|
||||
static Message text(const QString &role, const QString &text)
|
||||
{
|
||||
Message m;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
@@ -15,14 +15,15 @@ class ResponseCleaner
|
||||
public:
|
||||
static QString clean(const QString &response)
|
||||
{
|
||||
QString cleaned = removeCodeBlocks(response);
|
||||
bool extractedFromFence = false;
|
||||
QString cleaned = removeCodeBlocks(response, extractedFromFence);
|
||||
cleaned = trimWhitespace(cleaned);
|
||||
cleaned = removeExplanations(cleaned);
|
||||
cleaned = removeExplanations(cleaned, extractedFromFence);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private:
|
||||
static QString removeCodeBlocks(const QString &text)
|
||||
static QString removeCodeBlocks(const QString &text, bool &extractedFromFence)
|
||||
{
|
||||
if (!text.contains("```")) {
|
||||
return text;
|
||||
@@ -31,6 +32,7 @@ private:
|
||||
QRegularExpression codeBlockRegex("```\\w*\\n([\\s\\S]*?)```");
|
||||
QRegularExpressionMatch match = codeBlockRegex.match(text);
|
||||
if (match.hasMatch()) {
|
||||
extractedFromFence = true;
|
||||
return match.captured(1);
|
||||
}
|
||||
|
||||
@@ -39,6 +41,7 @@ private:
|
||||
if (firstFence != -1 && lastFence > firstFence) {
|
||||
int firstNewLine = text.indexOf('\n', firstFence);
|
||||
if (firstNewLine != -1) {
|
||||
extractedFromFence = true;
|
||||
return text.mid(firstNewLine + 1, lastFence - firstNewLine - 1);
|
||||
}
|
||||
}
|
||||
@@ -58,13 +61,34 @@ private:
|
||||
return result;
|
||||
}
|
||||
|
||||
static QString removeExplanations(const QString &text)
|
||||
static bool isProseHeader(const QString &line)
|
||||
{
|
||||
static const QStringList explanationPrefixes = {
|
||||
"here's the", "here is the", "here's", "here is",
|
||||
"the refactored", "refactored code:", "code:",
|
||||
"i've refactored", "i refactored", "i've changed", "i changed"
|
||||
};
|
||||
if (line.length() >= 50 || !line.endsWith(':') || !line.contains(' ')) {
|
||||
return false;
|
||||
}
|
||||
const QString head = line.left(line.length() - 1);
|
||||
for (const QChar c : head) {
|
||||
if (!c.isLetter() && c != ' ') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static QString removeExplanations(const QString &text, bool extractedFromFence)
|
||||
{
|
||||
static const QStringList explanationPrefixes
|
||||
= {"here's the",
|
||||
"here is the",
|
||||
"here's",
|
||||
"here is",
|
||||
"the refactored",
|
||||
"refactored code:",
|
||||
"code:",
|
||||
"i've refactored",
|
||||
"i refactored",
|
||||
"i've changed",
|
||||
"i changed"};
|
||||
|
||||
QStringList lines = text.split('\n');
|
||||
int startLine = 0;
|
||||
@@ -80,7 +104,7 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
if (line.length() < 50 && line.endsWith(':')) {
|
||||
if (!extractedFromFence && isProseHeader(line)) {
|
||||
isExplanation = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ inline void applyToSystem(QJsonObject &request, const QJsonObject &cacheControl)
|
||||
if (sys.isString()) {
|
||||
const QString text = sys.toString();
|
||||
if (!text.isEmpty()) {
|
||||
request["system"] = QJsonArray{QJsonObject{
|
||||
{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}};
|
||||
request["system"] = QJsonArray{
|
||||
QJsonObject{{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}};
|
||||
}
|
||||
} else if (sys.isArray()) {
|
||||
QJsonArray blocks = sys.toArray();
|
||||
|
||||
@@ -73,8 +73,6 @@ QString GenericProvider::modelsEndpoint(const QString &url) const
|
||||
RequestID GenericProvider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
// Gemini carries the model in the URL and rejects unknown body fields, so
|
||||
// the model/stream keys injected by the generic pipeline must be dropped.
|
||||
if (m_id == ProviderID::GoogleAI) {
|
||||
QJsonObject cleaned = payload;
|
||||
cleaned.remove("model");
|
||||
@@ -98,9 +96,7 @@ GenericProvider::ClientFactory makeFactory()
|
||||
|
||||
void registerBuiltinProviders()
|
||||
{
|
||||
const auto reg = [](const QString &api,
|
||||
ProviderID id,
|
||||
GenericProvider::ClientFactory factory) {
|
||||
const auto reg = [](const QString &api, ProviderID id, GenericProvider::ClientFactory factory) {
|
||||
ProviderFactory::registerType(api, [=](QObject *parent) -> Provider * {
|
||||
return new GenericProvider(api, id, factory, parent);
|
||||
});
|
||||
@@ -109,21 +105,23 @@ void registerBuiltinProviders()
|
||||
reg("Claude", ProviderID::Claude, makeFactory<::LLMQore::ClaudeClient>());
|
||||
reg("Google AI", ProviderID::GoogleAI, makeFactory<::LLMQore::GoogleAIClient>());
|
||||
reg("llama.cpp", ProviderID::LlamaCpp, makeFactory<::LLMQore::LlamaCppClient>());
|
||||
reg("LM Studio (Chat Completions)", ProviderID::LMStudio,
|
||||
reg("LM Studio (Chat Completions)",
|
||||
ProviderID::LMStudio,
|
||||
makeFactory<::LLMQore::OpenAIClient>());
|
||||
reg("LM Studio (Responses API)", ProviderID::OpenAIResponses,
|
||||
reg("LM Studio (Responses API)",
|
||||
ProviderID::OpenAIResponses,
|
||||
makeFactory<::LLMQore::OpenAIResponsesClient>());
|
||||
reg("Mistral AI", ProviderID::MistralAI, makeFactory<::LLMQore::MistralClient>());
|
||||
reg("Codestral", ProviderID::MistralAI, makeFactory<::LLMQore::MistralClient>());
|
||||
reg("Ollama (Native)", ProviderID::Ollama, makeFactory<::LLMQore::OllamaClient>());
|
||||
reg("Ollama (OpenAI-compatible)", ProviderID::OpenAICompatible,
|
||||
reg("Ollama (OpenAI-compatible)",
|
||||
ProviderID::OpenAICompatible,
|
||||
makeFactory<::LLMQore::OpenAIClient>());
|
||||
reg("OpenAI (Chat Completions)", ProviderID::OpenAI,
|
||||
makeFactory<::LLMQore::OpenAIClient>());
|
||||
reg("OpenAI (Responses API)", ProviderID::OpenAIResponses,
|
||||
reg("OpenAI (Chat Completions)", ProviderID::OpenAI, makeFactory<::LLMQore::OpenAIClient>());
|
||||
reg("OpenAI (Responses API)",
|
||||
ProviderID::OpenAIResponses,
|
||||
makeFactory<::LLMQore::OpenAIResponsesClient>());
|
||||
reg("OpenAI Compatible", ProviderID::OpenAICompatible,
|
||||
makeFactory<::LLMQore::OpenAIClient>());
|
||||
reg("OpenAI Compatible", ProviderID::OpenAICompatible, makeFactory<::LLMQore::OpenAIClient>());
|
||||
reg("OpenRouter", ProviderID::OpenRouter, makeFactory<::LLMQore::OpenAIClient>());
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ class BaseClient;
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
// A configuration-driven provider: it owns an LLMQore client and exposes a
|
||||
// fixed identity. Concrete behaviour (request shape) comes from the agent's
|
||||
// prompt template via Provider::prepareRequest, so a single class covers
|
||||
// every client_api by varying the client factory + metadata.
|
||||
class GenericProvider : public Provider
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -25,10 +21,7 @@ public:
|
||||
using ClientFactory = std::function<::LLMQore::BaseClient *(QObject *)>;
|
||||
|
||||
GenericProvider(
|
||||
QString name,
|
||||
ProviderID id,
|
||||
const ClientFactory &clientFactory,
|
||||
QObject *parent = nullptr);
|
||||
QString name, ProviderID id, const ClientFactory &clientFactory, QObject *parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
|
||||
@@ -46,8 +39,6 @@ private:
|
||||
::LLMQore::BaseClient *m_client;
|
||||
};
|
||||
|
||||
// Registers every built-in client_api into ProviderFactory. Must be called once
|
||||
// at plugin startup before any agent/session is created.
|
||||
void registerBuiltinProviders();
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
|
||||
@@ -40,14 +40,13 @@ bool Provider::prepareRequest(
|
||||
return fail(QString("Provider '%1': null template").arg(name()));
|
||||
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
return fail(QString("Template '%1' doesn't support provider '%2'")
|
||||
.arg(prompt->name(), name()));
|
||||
return fail(
|
||||
QString("Template '%1' doesn't support provider '%2'").arg(prompt->name(), name()));
|
||||
}
|
||||
|
||||
if (!prompt->buildFullRequest(request, context)) {
|
||||
return fail(
|
||||
QString("Provider '%1': template '%2' failed to build request (see log)")
|
||||
.arg(name(), prompt->name()));
|
||||
return fail(QString("Provider '%1': template '%2' failed to build request (see log)")
|
||||
.arg(name(), prompt->name()));
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
@@ -58,18 +57,23 @@ bool Provider::prepareRequest(
|
||||
}
|
||||
|
||||
if (m_promptCachingEnabled)
|
||||
ClaudeCacheControl::apply(
|
||||
request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints);
|
||||
ClaudeCacheControl::apply(request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Provider::setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints)
|
||||
{
|
||||
auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client());
|
||||
if (enabled && !claude) {
|
||||
LOG_MESSAGE(
|
||||
QString("%1: cache_prompt is only supported by Claude providers, ignoring").arg(name()));
|
||||
enabled = false;
|
||||
}
|
||||
m_promptCachingEnabled = enabled;
|
||||
m_promptCachingExtendedTtl = enabled && extendedTtl;
|
||||
m_promptCacheBreakpoints = breakpoints;
|
||||
if (auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client()))
|
||||
if (claude)
|
||||
claude->setUseExtendedCacheTTL(m_promptCachingExtendedTtl);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <utils/environment.h>
|
||||
|
||||
#include "ContextData.hpp"
|
||||
#include "ProviderID.hpp"
|
||||
#include "LLMQore/BaseClient.hpp"
|
||||
|
||||
namespace LLMQore {
|
||||
class BaseClient;
|
||||
class ToolsManager;
|
||||
}
|
||||
|
||||
@@ -63,8 +61,7 @@ public:
|
||||
void cancelRequest(const RequestID &requestId);
|
||||
::LLMQore::ToolsManager *toolsManager() const;
|
||||
|
||||
void setPromptCaching(
|
||||
bool enabled, bool extendedTtl, const QStringList &breakpoints = {});
|
||||
void setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints = {});
|
||||
|
||||
private:
|
||||
QString m_url;
|
||||
|
||||
8
sources/providersConfig/deepseek.toml
Normal file
8
sources/providersConfig/deepseek.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
schema_version = 1
|
||||
|
||||
name = "DeepSeek"
|
||||
client_api = "OpenAI Compatible"
|
||||
description = "Cloud (DeepSeek). OpenAI-compatible Chat Completions API — deepseek-chat (V4 family) and deepseek-reasoner. Get an API key at platform.deepseek.com."
|
||||
|
||||
url = "https://api.deepseek.com/v1"
|
||||
api_key_ref = "qodeassist/providers/DeepSeek"
|
||||
@@ -13,5 +13,7 @@
|
||||
<file>codestral.toml</file>
|
||||
<file>googleai.toml</file>
|
||||
<file>llamacpp.toml</file>
|
||||
<file>deepseek.toml</file>
|
||||
<file>qwen.toml</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
||||
8
sources/providersConfig/qwen.toml
Normal file
8
sources/providersConfig/qwen.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
schema_version = 1
|
||||
|
||||
name = "Qwen"
|
||||
client_api = "OpenAI Compatible"
|
||||
description = "Cloud (Alibaba Qwen via DashScope, international endpoint). OpenAI-compatible Chat Completions API — qwen-plus / qwen-max / qwen-coder model families. Get an API key in the Alibaba Cloud Model Studio console."
|
||||
|
||||
url = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
api_key_ref = "qodeassist/providers/Qwen"
|
||||
@@ -33,12 +33,12 @@ namespace {
|
||||
|
||||
enum class PillKind {
|
||||
Template,
|
||||
On, // capability on (thinking/tools)
|
||||
Off, // capability off ("plain")
|
||||
User, // user-defined agent
|
||||
Active, // ✓ active
|
||||
Match, // matched-this-row chip background
|
||||
Tag, // free-form discoverability tag from AgentConfig::tags
|
||||
On,
|
||||
Off,
|
||||
User,
|
||||
Active,
|
||||
Match,
|
||||
Tag,
|
||||
Neutral,
|
||||
};
|
||||
|
||||
@@ -192,16 +192,17 @@ class AgentRosterRow : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
AgentRosterRow(int index,
|
||||
const QString &name,
|
||||
const AgentConfig *cfg,
|
||||
const QString &model,
|
||||
bool active,
|
||||
bool first,
|
||||
bool last,
|
||||
bool orderable,
|
||||
const Theme &theme,
|
||||
QWidget *parent = nullptr);
|
||||
AgentRosterRow(
|
||||
int index,
|
||||
const QString &name,
|
||||
const AgentConfig *cfg,
|
||||
const QString &model,
|
||||
bool active,
|
||||
bool first,
|
||||
bool last,
|
||||
bool orderable,
|
||||
const Theme &theme,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void moveUpRequested(int index);
|
||||
@@ -221,17 +222,19 @@ private:
|
||||
int m_index;
|
||||
};
|
||||
|
||||
AgentRosterRow::AgentRosterRow(int index,
|
||||
const QString &name,
|
||||
const AgentConfig *cfg,
|
||||
const QString &model,
|
||||
bool active,
|
||||
bool first,
|
||||
bool last,
|
||||
bool orderable,
|
||||
const Theme &theme,
|
||||
QWidget *parent)
|
||||
: QFrame(parent), m_index(index)
|
||||
AgentRosterRow::AgentRosterRow(
|
||||
int index,
|
||||
const QString &name,
|
||||
const AgentConfig *cfg,
|
||||
const QString &model,
|
||||
bool active,
|
||||
bool first,
|
||||
bool last,
|
||||
bool orderable,
|
||||
const Theme &theme,
|
||||
QWidget *parent)
|
||||
: QFrame(parent)
|
||||
, m_index(index)
|
||||
{
|
||||
setAutoFillBackground(true);
|
||||
QPalette pal = palette();
|
||||
@@ -334,8 +337,8 @@ QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
|
||||
return w;
|
||||
}
|
||||
|
||||
QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch,
|
||||
const Theme &t)
|
||||
QWidget *AgentRosterRow::buildMetaLine(
|
||||
const AgentConfig *cfg, bool active, bool showMatch, const Theme &t)
|
||||
{
|
||||
auto *w = new QWidget(this);
|
||||
auto *line = new QHBoxLayout(w);
|
||||
@@ -352,14 +355,16 @@ QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool
|
||||
QString chipText = QStringLiteral(
|
||||
"<span style='opacity:0.7'>%1</span> "
|
||||
"<span style='color:%2'>%3:</span> %4")
|
||||
.arg(sm.icon,
|
||||
hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
|
||||
sm.kind,
|
||||
sm.value.toHtmlEscaped());
|
||||
.arg(
|
||||
sm.icon,
|
||||
hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
|
||||
sm.kind,
|
||||
sm.value.toHtmlEscaped());
|
||||
chip->setTextFormat(Qt::RichText);
|
||||
chip->setText(chipText);
|
||||
chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;"
|
||||
"padding:1px 6px; border-radius:3px; font-size:11px;")
|
||||
chip->setStyleSheet(QStringLiteral(
|
||||
"background:%1; color:%2; border:1px solid %3;"
|
||||
"padding:1px 6px; border-radius:3px; font-size:11px;")
|
||||
.arg(hex(bg), hex(fg), hex(bd)));
|
||||
chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||
line->addWidget(chip);
|
||||
@@ -457,14 +462,14 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
|
||||
m_rowsFrame = new QFrame(this);
|
||||
m_rowsFrame->setObjectName(QStringLiteral("rosterCard"));
|
||||
m_rowsFrame->setStyleSheet(
|
||||
QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:4px; }")
|
||||
QStringLiteral(
|
||||
"QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:4px; }")
|
||||
.arg(hex(t.cardBg), hex(t.cardBorder)));
|
||||
m_rowsLayout = new QVBoxLayout(m_rowsFrame);
|
||||
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_rowsLayout->setSpacing(0);
|
||||
|
||||
m_emptyHint = new QLabel(tr("No agent selected yet — use \"Add agent…\" below."),
|
||||
m_rowsFrame);
|
||||
m_emptyHint = new QLabel(tr("No agent selected yet — use \"Add agent…\" below."), m_rowsFrame);
|
||||
m_emptyHint->setAlignment(Qt::AlignCenter);
|
||||
m_emptyHint->setContentsMargins(10, 12, 10, 12);
|
||||
QFont eh = m_emptyHint->font();
|
||||
@@ -497,9 +502,7 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
|
||||
}
|
||||
|
||||
void AgentRosterWidget::setSlot(
|
||||
const QString &title,
|
||||
const QString &hint,
|
||||
const QStringList &presetTags)
|
||||
const QString &title, const QString &hint, const QStringList &presetTags)
|
||||
{
|
||||
m_presetTags = presetTags;
|
||||
m_titleLabel->setText(title);
|
||||
@@ -514,13 +517,14 @@ void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factor
|
||||
QObject::disconnect(m_modelConn);
|
||||
m_factory = factory;
|
||||
if (m_factory) {
|
||||
m_factoryConn = connect(m_factory, &AgentFactory::agentsChanged, this,
|
||||
[this] { rebuildRows(); });
|
||||
m_modelConn = connect(m_factory, &AgentFactory::agentModelChanged, this,
|
||||
[this](const QString &name) {
|
||||
if (m_names.contains(name))
|
||||
rebuildRows();
|
||||
});
|
||||
m_factoryConn = connect(m_factory, &AgentFactory::agentsChanged, this, [this] {
|
||||
rebuildRows();
|
||||
});
|
||||
m_modelConn = connect(
|
||||
m_factory, &AgentFactory::agentModelChanged, this, [this](const QString &name) {
|
||||
if (m_names.contains(name))
|
||||
rebuildRows();
|
||||
});
|
||||
}
|
||||
}
|
||||
m_names = names;
|
||||
@@ -558,6 +562,13 @@ void AgentRosterWidget::applyMode()
|
||||
m_footerHint->setVisible(!footer.isEmpty());
|
||||
}
|
||||
|
||||
void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx)
|
||||
{
|
||||
m_routingCtx = ctx;
|
||||
if (!m_names.isEmpty())
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void AgentRosterWidget::recomputeActive()
|
||||
{
|
||||
if (!m_orderable || !m_factory || m_names.isEmpty()
|
||||
@@ -571,7 +582,6 @@ void AgentRosterWidget::recomputeActive()
|
||||
|
||||
void AgentRosterWidget::rebuildRows()
|
||||
{
|
||||
// Tear down existing row widgets (keep the empty hint).
|
||||
while (m_rowsLayout->count() > 0) {
|
||||
QLayoutItem *it = m_rowsLayout->takeAt(0);
|
||||
if (auto *w = it->widget()) {
|
||||
@@ -598,16 +608,17 @@ void AgentRosterWidget::rebuildRows()
|
||||
const QString &name = m_names.at(i);
|
||||
const AgentConfig *cfg = m_factory ? m_factory->configByName(name) : nullptr;
|
||||
const QString model = cfg ? cfg->model : QString();
|
||||
auto *row = new AgentRosterRow(i,
|
||||
name,
|
||||
cfg,
|
||||
model,
|
||||
i == m_activeIndex,
|
||||
/*first*/ i == 0,
|
||||
/*last*/ i == m_names.size() - 1,
|
||||
m_orderable,
|
||||
t,
|
||||
m_rowsFrame);
|
||||
auto *row = new AgentRosterRow(
|
||||
i,
|
||||
name,
|
||||
cfg,
|
||||
model,
|
||||
i == m_activeIndex,
|
||||
/*first*/ i == 0,
|
||||
/*last*/ i == m_names.size() - 1,
|
||||
m_orderable,
|
||||
t,
|
||||
m_rowsFrame);
|
||||
connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp);
|
||||
connect(row, &AgentRosterRow::moveDownRequested, this, &AgentRosterWidget::onRowMoveDown);
|
||||
connect(row, &AgentRosterRow::editRequested, this, &AgentRosterWidget::onRowEdit);
|
||||
@@ -621,11 +632,12 @@ void AgentRosterWidget::onAddClicked()
|
||||
if (!m_factory)
|
||||
return;
|
||||
|
||||
AgentSelectionDialog dialog(m_factory->configs(),
|
||||
/*currentName*/ m_single ? m_names.value(0) : QString{},
|
||||
m_factory.data(),
|
||||
m_presetTags,
|
||||
Core::ICore::dialogParent());
|
||||
AgentSelectionDialog dialog(
|
||||
m_factory->configs(),
|
||||
/*currentName*/ m_single ? m_names.value(0) : QString{},
|
||||
m_factory.data(),
|
||||
m_presetTags,
|
||||
Core::ICore::dialogParent());
|
||||
if (dialog.exec() != QDialog::Accepted)
|
||||
return;
|
||||
const QString picked = dialog.selectedName();
|
||||
|
||||
@@ -31,20 +31,12 @@ class AgentRosterWidget : public QWidget
|
||||
public:
|
||||
explicit AgentRosterWidget(QWidget *parent = nullptr);
|
||||
|
||||
void setSlot(
|
||||
const QString &title,
|
||||
const QString &hint,
|
||||
const QStringList &presetTags = {});
|
||||
void setSlot(const QString &title, const QString &hint, const QStringList &presetTags = {});
|
||||
void setRoster(const QStringList &names, AgentFactory *factory);
|
||||
void setRoutingContext(const AgentRouter::Context &ctx);
|
||||
|
||||
// When false, the list is an unordered set: no move arrows, no position
|
||||
// numbers, no "first matching" routing hint. Used by pipelines where the
|
||||
// user — not a router — chooses the agent (e.g. the chat picker).
|
||||
void setOrderable(bool orderable);
|
||||
|
||||
// When true, the card holds at most one agent: "Add" becomes "Change",
|
||||
// selecting replaces the current entry, and the routing footer is hidden.
|
||||
// Implies a non-orderable list. Used by single-agent pipelines.
|
||||
void setSingle(bool single);
|
||||
|
||||
[[nodiscard]] QStringList roster() const { return m_names; }
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
#include "AgentSelectionDialog.hpp"
|
||||
|
||||
#include "PipelinesConfig.hpp"
|
||||
#include "Pill.hpp"
|
||||
#include "PipelinesConfig.hpp"
|
||||
#include "SettingsTr.hpp"
|
||||
#include "TagFilterStrip.hpp"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QEnterEvent>
|
||||
#include <QEvent>
|
||||
@@ -27,12 +28,9 @@
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
// -- ListRowCard -------------------------------------------------------
|
||||
|
||||
ListRowCard::ListRowCard(QWidget *parent)
|
||||
: QFrame(parent)
|
||||
{
|
||||
@@ -128,16 +126,13 @@ void ListRowCard::applyTheme()
|
||||
.arg(bg, bd));
|
||||
}
|
||||
|
||||
// -- AgentRowCard ------------------------------------------------------
|
||||
|
||||
AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
|
||||
: ListRowCard(parent)
|
||||
{
|
||||
setItemName(cfg.name);
|
||||
setItemTags(cfg.tags);
|
||||
QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
|
||||
cfg.description, cfg.systemPrompt,
|
||||
cfg.endpoint};
|
||||
QStringList haystack{
|
||||
cfg.name, cfg.providerInstance, cfg.model, cfg.description, cfg.systemPrompt, cfg.endpoint};
|
||||
haystack += cfg.tags;
|
||||
buildSearchHaystack(haystack);
|
||||
|
||||
@@ -249,8 +244,6 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
|
||||
setToolTip(tooltip.trimmed());
|
||||
}
|
||||
|
||||
// -- ProviderSection ---------------------------------------------------
|
||||
|
||||
ProviderSection::ProviderSection(const QString &name, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
@@ -337,8 +330,6 @@ bool ProviderSection::eventFilter(QObject *watched, QEvent *event)
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
// -- AgentSelectionDialog ----------------------------------------------
|
||||
|
||||
AgentSelectionDialog::AgentSelectionDialog(
|
||||
const std::vector<AgentConfig> &configs,
|
||||
const QString ¤tName,
|
||||
@@ -380,10 +371,10 @@ AgentSelectionDialog::AgentSelectionDialog(
|
||||
m_resultCount->setPalette(rp);
|
||||
}
|
||||
|
||||
auto *expandAll = new QLabel(
|
||||
QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Expand all")), this);
|
||||
auto *collapseAll = new QLabel(
|
||||
QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Collapse all")), this);
|
||||
auto *expandAll
|
||||
= new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Expand all")), this);
|
||||
auto *collapseAll
|
||||
= new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Collapse all")), this);
|
||||
|
||||
auto *controlsRow = new QHBoxLayout;
|
||||
controlsRow->setContentsMargins(2, 0, 2, 0);
|
||||
@@ -425,8 +416,9 @@ AgentSelectionDialog::AgentSelectionDialog(
|
||||
preset.insert(tag);
|
||||
m_tagStrip->setAvailableTags(tagCounts, preset);
|
||||
|
||||
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
|
||||
[this](const QSet<QString> &) { applyFilters(); });
|
||||
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this, [this](const QSet<QString> &) {
|
||||
applyFilters();
|
||||
});
|
||||
connect(expandAll, &QLabel::linkActivated, this, [this](const QString &) {
|
||||
setAllExpanded(true);
|
||||
});
|
||||
@@ -436,8 +428,7 @@ AgentSelectionDialog::AgentSelectionDialog(
|
||||
|
||||
rebuild(currentName);
|
||||
|
||||
connect(m_filter, &QLineEdit::textChanged, this,
|
||||
[this](const QString &) { applyFilters(); });
|
||||
connect(m_filter, &QLineEdit::textChanged, this, [this](const QString &) { applyFilters(); });
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
@@ -486,7 +477,8 @@ void AgentSelectionDialog::rebuild(const QString ¤tName)
|
||||
|
||||
QMap<QString, std::vector<const AgentConfig *>> byProvider;
|
||||
for (const auto &cfg : configs) {
|
||||
if (cfg.hidden) continue; // hidden profiles stay loaded but don't surface in the picker
|
||||
if (cfg.hidden)
|
||||
continue;
|
||||
const QString key = cfg.providerInstance.isEmpty()
|
||||
? Tr::tr("(Unknown provider instance)")
|
||||
: cfg.providerInstance;
|
||||
@@ -565,8 +557,7 @@ void AgentSelectionDialog::applyFilters()
|
||||
if (m_emptyLabel)
|
||||
m_emptyLabel->setVisible(total == 0);
|
||||
if (m_resultCount)
|
||||
m_resultCount->setText(total == 0 ? tr("No matches")
|
||||
: tr("%n agent(s)", nullptr, total));
|
||||
m_resultCount->setText(total == 0 ? tr("No matches") : tr("%n agent(s)", nullptr, total));
|
||||
|
||||
if (m_tagStrip) {
|
||||
QMap<QString, int> liveCounts;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <QDialog>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
@@ -11,7 +12,6 @@
|
||||
#include <QPalette>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <vector>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
@@ -142,7 +142,6 @@ public:
|
||||
void setExpanded(bool expanded);
|
||||
const QList<ListRowCard *> &cards() const { return m_cards; }
|
||||
|
||||
// returns number of visible cards
|
||||
int applyFilter(const QString &needle, const QSet<QString> &activeTags);
|
||||
|
||||
protected:
|
||||
@@ -189,7 +188,7 @@ private:
|
||||
QString m_selectedName;
|
||||
|
||||
AgentFactory *m_agentFactory = nullptr;
|
||||
std::vector<AgentConfig> m_localConfigs; // fallback when no factory
|
||||
std::vector<AgentConfig> m_localConfigs;
|
||||
QStringList m_presetTags;
|
||||
};
|
||||
|
||||
|
||||
@@ -87,9 +87,10 @@ void Pill::applyTheme()
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
|
||||
const PillTone t = toneFor(m_kind);
|
||||
setStyleSheet(QStringLiteral("QLabel { background-color:%1; color:%2;"
|
||||
" border:1px solid %3; border-radius:3px;"
|
||||
" padding:1px 7px; font-size:11px; }")
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QLabel { background-color:%1; color:%2;"
|
||||
" border:1px solid %3; border-radius:3px;"
|
||||
" padding:1px 7px; font-size:11px; }")
|
||||
.arg(cssColor(t.bg), cssColor(t.fg), cssColor(t.border)));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
// Small rounded chip. Theme-aware: recolors itself on palette/style changes.
|
||||
// All colors derive from the active Qt Creator theme (Utils::Theme).
|
||||
class Pill : public QLabel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "TomlWriter.hpp"
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
@@ -45,8 +44,6 @@ QString toSingleString(const toml::node *node, const QString &slotKey, bool *sch
|
||||
return {};
|
||||
if (const auto *s = node->as_string())
|
||||
return trimAndCap(QString::fromStdString(s->get()));
|
||||
// Backward compatibility: older pipelines.toml stored these slots as an
|
||||
// ordered array. Collapse to the first usable name.
|
||||
if (const auto *arr = node->as_array()) {
|
||||
for (size_t i = 0; i < arr->size(); ++i) {
|
||||
if (const auto *s = (*arr)[i].as_string()) {
|
||||
@@ -115,19 +112,53 @@ void fillMissingFromDefaults(PipelineRosters &r, const toml::table §ion)
|
||||
r.quickRefactor = defs.quickRefactor;
|
||||
}
|
||||
|
||||
struct CacheState
|
||||
{
|
||||
PipelinesLoadResult result;
|
||||
QDateTime mtime;
|
||||
qint64 size = -1;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
CacheState &cacheState()
|
||||
{
|
||||
static CacheState s;
|
||||
return s;
|
||||
}
|
||||
|
||||
QString &filePathOverride()
|
||||
{
|
||||
static QString p;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PipelineRosters PipelineRosters::defaults()
|
||||
{
|
||||
return PipelineRosters{};
|
||||
return PipelineRosters{
|
||||
{QStringLiteral("Ollama Completion — FIM")},
|
||||
{QStringLiteral("Ollama Chat — Simple"),
|
||||
QStringLiteral("Ollama Chat — Thinking"),
|
||||
QStringLiteral("Ollama Chat — Gemma 4")},
|
||||
QStringLiteral("Ollama Compression — 8 GB"),
|
||||
QStringLiteral("Ollama Quick Refactor — Simple")};
|
||||
}
|
||||
|
||||
QString PipelinesConfig::filePath()
|
||||
{
|
||||
if (!filePathOverride().isEmpty())
|
||||
return filePathOverride();
|
||||
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/pipelines.toml"))
|
||||
.toFSPathString();
|
||||
}
|
||||
|
||||
void PipelinesConfig::setFilePathForTests(const QString &path)
|
||||
{
|
||||
filePathOverride() = path;
|
||||
cacheState() = {};
|
||||
}
|
||||
|
||||
PipelinesLoadResult PipelinesConfig::load()
|
||||
{
|
||||
PipelinesLoadResult result;
|
||||
@@ -206,19 +237,18 @@ PipelinesLoadResult PipelinesConfig::load()
|
||||
|
||||
PipelinesLoadResult PipelinesConfig::loadCached()
|
||||
{
|
||||
static PipelinesLoadResult cached;
|
||||
static QDateTime cachedMTime;
|
||||
static bool valid = false;
|
||||
|
||||
auto &cache = cacheState();
|
||||
const QFileInfo info(filePath());
|
||||
const QDateTime mtime = info.exists() ? info.lastModified() : QDateTime();
|
||||
if (valid && mtime == cachedMTime)
|
||||
return cached;
|
||||
const qint64 size = info.exists() ? info.size() : -1;
|
||||
if (cache.valid && mtime == cache.mtime && size == cache.size)
|
||||
return cache.result;
|
||||
|
||||
cached = load();
|
||||
cachedMTime = mtime;
|
||||
valid = true;
|
||||
return cached;
|
||||
cache.result = load();
|
||||
cache.mtime = mtime;
|
||||
cache.size = size;
|
||||
cache.valid = true;
|
||||
return cache.result;
|
||||
}
|
||||
|
||||
bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
|
||||
@@ -236,15 +266,14 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
|
||||
|
||||
TomlSerializer::TomlWriter w;
|
||||
w.writeComment(QStringLiteral("QodeAssist pipelines — which agent each feature uses."));
|
||||
w.writeComment(QStringLiteral(
|
||||
"code_completion: ordered list; the router walks it top-down and uses"));
|
||||
w.writeComment(QStringLiteral(
|
||||
" the first agent whose match rules fit the current file/project."));
|
||||
w.writeComment(QStringLiteral(
|
||||
"chat_assistant: agents offered in the chat picker (order irrelevant —"));
|
||||
w.writeComment(
|
||||
QStringLiteral("code_completion: ordered list; the router walks it top-down and uses"));
|
||||
w.writeComment(
|
||||
QStringLiteral(" the first agent whose match rules fit the current file/project."));
|
||||
w.writeComment(
|
||||
QStringLiteral("chat_assistant: agents offered in the chat picker (order irrelevant —"));
|
||||
w.writeComment(QStringLiteral(" you choose one in the UI)."));
|
||||
w.writeComment(QStringLiteral(
|
||||
"chat_compression / quick_refactor: a single agent name."));
|
||||
w.writeComment(QStringLiteral("chat_compression / quick_refactor: a single agent name."));
|
||||
w.writeBlankLine();
|
||||
w.writeTableHeader(QString::fromUtf8(kSection));
|
||||
w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion);
|
||||
@@ -276,56 +305,8 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
|
||||
*errorOut = out.errorString();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString *errorOut)
|
||||
{
|
||||
PipelinesLoadResult lr = load();
|
||||
PipelineRosters &r = lr.rosters;
|
||||
bool changed = false;
|
||||
|
||||
auto fix = [&](QStringList ¤t) {
|
||||
QStringList kept;
|
||||
kept.reserve(current.size());
|
||||
for (const QString &raw : current) {
|
||||
const QString name = trimAndCap(raw);
|
||||
if (name.isEmpty() || kept.contains(name))
|
||||
continue;
|
||||
if (factory.configByName(name))
|
||||
kept.append(name);
|
||||
}
|
||||
if (kept != current) {
|
||||
current = std::move(kept);
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
auto fixOne = [&](QString ¤t) {
|
||||
const QString name = trimAndCap(current);
|
||||
const QString next = (!name.isEmpty() && factory.configByName(name)) ? name : QString();
|
||||
if (next != current) {
|
||||
current = next;
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
fix(r.codeCompletion);
|
||||
fix(r.chatAssistant);
|
||||
fixOne(r.chatCompression);
|
||||
fixOne(r.quickRefactor);
|
||||
|
||||
if (!changed && lr.status == PipelinesLoadStatus::Ok)
|
||||
return true;
|
||||
|
||||
QString saveErr;
|
||||
if (!save(r, &saveErr)) {
|
||||
const QString msg = QStringLiteral("failed to persist after validation: %1").arg(saveErr);
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(msg));
|
||||
if (errorOut)
|
||||
*errorOut = msg;
|
||||
return false;
|
||||
}
|
||||
cacheState() = {};
|
||||
PipelinesNotifier::instance()->notifyChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,35 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class PipelinesNotifier : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
static PipelinesNotifier *instance()
|
||||
{
|
||||
static PipelinesNotifier notifier;
|
||||
return ¬ifier;
|
||||
}
|
||||
|
||||
void notifyChanged() { emit pipelinesChanged(); }
|
||||
|
||||
signals:
|
||||
void pipelinesChanged();
|
||||
|
||||
private:
|
||||
PipelinesNotifier() = default;
|
||||
};
|
||||
|
||||
struct PipelineRosters
|
||||
{
|
||||
// Code completion is auto-routed: the router walks this ordered list at
|
||||
// request time and uses the first agent whose match rules fit the file.
|
||||
QStringList codeCompletion;
|
||||
// Chat is user-driven: this is an unordered allow-list of the agents
|
||||
// offered in the chat picker. The user picks; no routing happens.
|
||||
QStringList chatAssistant;
|
||||
// Compression and quick refactor each use a single fixed agent.
|
||||
QString chatCompression;
|
||||
QString quickRefactor;
|
||||
|
||||
@@ -48,8 +59,7 @@ public:
|
||||
|
||||
[[nodiscard]] static bool save(const PipelineRosters &rosters, QString *errorOut = nullptr);
|
||||
|
||||
[[nodiscard]] static bool validate(
|
||||
const QodeAssist::AgentFactory &factory, QString *errorOut = nullptr);
|
||||
static void setFilePathForTests(const QString &path);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
@@ -46,8 +46,6 @@ nlohmann::json buildContextJson(const ContextData &context)
|
||||
ctx["files_metadata"] = std::move(files);
|
||||
}
|
||||
|
||||
// tool_result blocks only carry the tool_use_id; resolve the originating
|
||||
// tool name so templates (e.g. Google's functionResponse.name) can emit it.
|
||||
QHash<QString, QString> toolNameById;
|
||||
if (context.history) {
|
||||
for (const auto &msg : context.history.value())
|
||||
@@ -89,8 +87,7 @@ nlohmann::json buildContextJson(const ContextData &context)
|
||||
bj["name"] = b.toolName.toStdString();
|
||||
const std::string inputStr
|
||||
= QJsonDocument(b.toolInput).toJson(QJsonDocument::Compact).toStdString();
|
||||
nlohmann::json parsedInput
|
||||
= nlohmann::json::parse(inputStr, nullptr, /*allow_exceptions=*/false);
|
||||
nlohmann::json parsedInput = nlohmann::json::parse(inputStr, nullptr, false);
|
||||
if (parsedInput.is_discarded()) {
|
||||
if (!b.toolInput.isEmpty()) {
|
||||
qWarning("[QodeAssist] tool_use '%s' has unparseable input "
|
||||
@@ -141,12 +138,6 @@ nlohmann::json buildContextJson(const ContextData &context)
|
||||
return data;
|
||||
}
|
||||
|
||||
// JSON-aware removal of trailing commas (a `,` immediately followed, after
|
||||
// optional whitespace, by `}` or `]`). Body partials emit an unconditional
|
||||
// comma after every array element / object member; this pass deletes the
|
||||
// dangling one before the closing bracket so the result parses as strict
|
||||
// JSON. String literals are skipped, so commas inside string values (e.g. a
|
||||
// tool result containing "],") are never touched.
|
||||
std::string stripTrailingCommas(const std::string &in)
|
||||
{
|
||||
std::string out;
|
||||
@@ -176,23 +167,19 @@ std::string stripTrailingCommas(const std::string &in)
|
||||
&& (in[j] == ' ' || in[j] == '\t' || in[j] == '\n' || in[j] == '\r'))
|
||||
++j;
|
||||
if (j < in.size() && (in[j] == '}' || in[j] == ']'))
|
||||
continue; // drop this comma
|
||||
continue;
|
||||
}
|
||||
out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Install a sandboxed `{% include %}` resolver. Includes resolve only against
|
||||
// the given roots (bundled qrc partials, then the user agent's own dir); names
|
||||
// containing ".." or starting with "/" are rejected. The included partial is
|
||||
// parsed in the same environment, so its own includes/callbacks resolve too.
|
||||
void setIncludeResolver(inja::Environment &env, std::vector<QString> roots)
|
||||
{
|
||||
inja::Environment *envPtr = &env;
|
||||
env.set_include_callback(
|
||||
[envPtr, roots = std::move(roots)](
|
||||
const std::filesystem::path &, const std::string &name) -> inja::Template {
|
||||
[envPtr, roots = std::move(roots)](const std::filesystem::path &, const std::string &name)
|
||||
-> inja::Template {
|
||||
const QString rel = QString::fromStdString(name);
|
||||
if (rel.contains(QStringLiteral("..")) || rel.startsWith(QLatin1Char('/'))) {
|
||||
throw inja::FileError("include rejected (path traversal): '" + name + "'");
|
||||
@@ -208,24 +195,14 @@ void setIncludeResolver(inja::Environment &env, std::vector<QString> roots)
|
||||
|
||||
void registerStandardCallbacks(inja::Environment &env)
|
||||
{
|
||||
// `{% include %}` resolution is wired per-instance in fromConfig() via a
|
||||
// whitelisted callback; disable inja's own filesystem search so the only
|
||||
// path is our sandboxed resolver.
|
||||
env.set_search_included_templates_in_files(false);
|
||||
|
||||
// Disable inja's `##` line-statement shorthand — collides with
|
||||
// Markdown headings inside template bodies. Same rationale as in
|
||||
// ContextRenderer; retarget to an unreachable sentinel.
|
||||
env.set_line_statement("@@@inja@@@");
|
||||
|
||||
env.add_callback("tojson", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
return args.at(0)->dump();
|
||||
});
|
||||
|
||||
// Returns the subset of a content_blocks array whose "type" equals the
|
||||
// second argument. Lets templates build provider-specific structures (e.g.
|
||||
// OpenAI message-level tool_calls / tool result messages) from a filtered
|
||||
// list with clean loop.is_first/is_last comma handling.
|
||||
env.add_callback("filter_by_type", 2, [](inja::Arguments &args) -> nlohmann::json {
|
||||
const nlohmann::json &blocks = *args.at(0);
|
||||
const std::string type = args.at(1)->get<std::string>();
|
||||
@@ -261,58 +238,26 @@ void registerStandardCallbacks(inja::Environment &env)
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
env.add_callback("filter_skip_empty_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
const nlohmann::json &history = *args.at(0);
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto &msg : history) {
|
||||
const bool isThinking = msg.value("is_thinking", false);
|
||||
const std::string sig = msg.value("signature", "");
|
||||
if (isThinking && sig.empty()) {
|
||||
continue;
|
||||
}
|
||||
result.push_back(msg);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
env.add_callback(
|
||||
"filter_skip_empty_parts_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
const nlohmann::json &history = *args.at(0);
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto &msg : history) {
|
||||
const bool isThinking = msg.value("is_thinking", false);
|
||||
const std::string content = msg.value("content", "");
|
||||
const std::string sig = msg.value("signature", "");
|
||||
if (isThinking && content.empty() && sig.empty()) {
|
||||
continue;
|
||||
}
|
||||
result.push_back(msg);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// A representative context for the load-time dry run: it populates every key a
|
||||
// body/partial might touch (system_prompt, prefix, suffix, and a history that
|
||||
// includes text, tool_use, tool_result and image blocks) so validation
|
||||
// exercises all branches without tripping on missing variables.
|
||||
ContextData makeValidationContext()
|
||||
{
|
||||
const QString hostile = QStringLiteral("va\"li\\da\ntion");
|
||||
|
||||
ContextData ctx;
|
||||
ctx.systemPrompt = QStringLiteral("validation");
|
||||
ctx.prefix = QStringLiteral("prefix");
|
||||
ctx.suffix = QStringLiteral("suffix");
|
||||
ctx.systemPrompt = QStringLiteral("system ") + hostile;
|
||||
ctx.prefix = QStringLiteral("prefix ") + hostile;
|
||||
ctx.suffix = QStringLiteral("suffix ") + hostile;
|
||||
|
||||
QVector<Message> history;
|
||||
history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello")));
|
||||
history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello ") + hostile));
|
||||
|
||||
Message asst;
|
||||
asst.role = QStringLiteral("assistant");
|
||||
{
|
||||
ContentBlockEntry th;
|
||||
th.kind = ContentBlockEntry::Kind::Thinking;
|
||||
th.thinking = QStringLiteral("reasoning");
|
||||
th.thinking = QStringLiteral("reasoning ") + hostile;
|
||||
th.signature = QStringLiteral("sig");
|
||||
asst.blocks.append(th);
|
||||
ContentBlockEntry rth;
|
||||
@@ -321,13 +266,13 @@ ContextData makeValidationContext()
|
||||
asst.blocks.append(rth);
|
||||
ContentBlockEntry t;
|
||||
t.kind = ContentBlockEntry::Kind::Text;
|
||||
t.text = QStringLiteral("hi");
|
||||
t.text = QStringLiteral("hi ") + hostile;
|
||||
asst.blocks.append(t);
|
||||
ContentBlockEntry tu;
|
||||
tu.kind = ContentBlockEntry::Kind::ToolUse;
|
||||
tu.toolUseId = QStringLiteral("call_1");
|
||||
tu.toolName = QStringLiteral("read_file");
|
||||
tu.toolInput = QJsonObject{{QStringLiteral("path"), QStringLiteral("x")}};
|
||||
tu.toolInput = QJsonObject{{QStringLiteral("path"), hostile}};
|
||||
asst.blocks.append(tu);
|
||||
}
|
||||
history.append(asst);
|
||||
@@ -338,7 +283,7 @@ ContextData makeValidationContext()
|
||||
ContentBlockEntry tr;
|
||||
tr.kind = ContentBlockEntry::Kind::ToolResult;
|
||||
tr.toolUseId = QStringLiteral("call_1");
|
||||
tr.result = QStringLiteral("ok");
|
||||
tr.result = QStringLiteral("ok ") + hostile;
|
||||
toolMsg.blocks.append(tr);
|
||||
}
|
||||
history.append(toolMsg);
|
||||
@@ -348,7 +293,7 @@ ContextData makeValidationContext()
|
||||
{
|
||||
ContentBlockEntry te;
|
||||
te.kind = ContentBlockEntry::Kind::Text;
|
||||
te.text = QStringLiteral("look");
|
||||
te.text = QStringLiteral("look ") + hostile;
|
||||
imgMsg.blocks.append(te);
|
||||
ContentBlockEntry im;
|
||||
im.kind = ContentBlockEntry::Kind::Image;
|
||||
@@ -391,11 +336,11 @@ std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
|
||||
registerStandardCallbacks(tpl->m_env);
|
||||
setIncludeResolver(tpl->m_env, tpl->m_partialRoots);
|
||||
|
||||
// Dry-run against a representative context: catches jinja syntax errors,
|
||||
// unknown callbacks and missing partials at load time instead of on first send.
|
||||
if (!tpl->renderBody(makeValidationContext())) {
|
||||
setError(QStringLiteral("Agent '%1' [body] failed to render to valid JSON "
|
||||
"(see log)").arg(cfg.name));
|
||||
setError(QStringLiteral(
|
||||
"Agent '%1' [body] failed to render to valid JSON "
|
||||
"(see log)")
|
||||
.arg(cfg.name));
|
||||
return nullptr;
|
||||
}
|
||||
return tpl;
|
||||
@@ -403,11 +348,6 @@ std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
|
||||
|
||||
namespace {
|
||||
|
||||
// Render one body value. A string containing jinja is rendered and its output
|
||||
// spliced in as raw JSON; a plain string and any scalar pass through unchanged;
|
||||
// objects/arrays recurse. A jinja string that renders to nothing sets `omit`
|
||||
// so the caller drops the key. Returns false on render / JSON-parse failure.
|
||||
// The caller must hold the render lock (inja's env is not re-entrant).
|
||||
bool renderValue(
|
||||
inja::Environment &env,
|
||||
const QString &tplName,
|
||||
@@ -463,8 +403,8 @@ bool renderValue(
|
||||
try {
|
||||
rendered = env.render(s.toStdString(), data);
|
||||
} catch (const std::exception &e) {
|
||||
qWarning("[QodeAssist] Template '%s' field render failed: %s",
|
||||
qUtf8Printable(tplName), e.what());
|
||||
qWarning(
|
||||
"[QodeAssist] Template '%s' field render failed: %s", qUtf8Printable(tplName), e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -474,17 +414,17 @@ bool renderValue(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wrap so ANY JSON value (array/object/string/number) parses via QJsonDocument.
|
||||
const std::string wrapped = "{\"v\":" + rendered + "}";
|
||||
QJsonParseError perr;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(QByteArray::fromStdString(wrapped), &perr);
|
||||
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
const QString snippet = QString::fromStdString(rendered).left(500);
|
||||
qWarning("[QodeAssist] Template '%s' field produced invalid JSON: %s\n"
|
||||
"--- rendered (truncated) ---\n%s",
|
||||
qUtf8Printable(tplName),
|
||||
qUtf8Printable(perr.errorString()),
|
||||
qUtf8Printable(snippet));
|
||||
qWarning(
|
||||
"[QodeAssist] Template '%s' field produced invalid JSON: %s\n"
|
||||
"--- rendered (truncated) ---\n%s",
|
||||
qUtf8Printable(tplName),
|
||||
qUtf8Printable(perr.errorString()),
|
||||
qUtf8Printable(snippet));
|
||||
return false;
|
||||
}
|
||||
out = doc.object().value(QStringLiteral("v"));
|
||||
@@ -519,13 +459,7 @@ std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &con
|
||||
return request;
|
||||
}
|
||||
|
||||
void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
|
||||
{
|
||||
mergeRenderedBody(request, renderBody(context));
|
||||
}
|
||||
|
||||
bool JsonPromptTemplate::buildFullRequest(
|
||||
QJsonObject &request, const ContextData &context) const
|
||||
bool JsonPromptTemplate::buildFullRequest(QJsonObject &request, const ContextData &context) const
|
||||
{
|
||||
return mergeRenderedBody(request, renderBody(context));
|
||||
}
|
||||
|
||||
@@ -22,28 +22,17 @@ struct AgentConfig;
|
||||
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
// Renderer for the request-body jinja template embedded in an
|
||||
// AgentConfig. One per Agent — built inline from the config (no shared
|
||||
// template registry, no model/provider filtering).
|
||||
class JsonPromptTemplate : public PromptTemplate
|
||||
{
|
||||
public:
|
||||
// Build a renderer from an already-parsed agent config. Compiles
|
||||
// the jinja source via inja once. On failure returns nullptr and
|
||||
// populates `*error` (existing content preserved; pass nullptr to
|
||||
// discard).
|
||||
static std::unique_ptr<JsonPromptTemplate> fromConfig(
|
||||
const AgentConfig &cfg, QString *error = nullptr);
|
||||
|
||||
QString name() const override { return m_name; }
|
||||
QString description() const override { return m_description; }
|
||||
|
||||
// Standalone-template filters are gone — each template is built for
|
||||
// exactly one agent, so it always matches its owner's provider/model.
|
||||
bool isSupportProvider(Providers::ProviderID) const override { return true; }
|
||||
|
||||
void prepareRequest(QJsonObject &request, const ContextData &context) const override;
|
||||
|
||||
[[nodiscard]] bool buildFullRequest(
|
||||
QJsonObject &request, const ContextData &context) const override;
|
||||
|
||||
@@ -54,21 +43,8 @@ private:
|
||||
|
||||
QString m_name;
|
||||
QString m_description;
|
||||
|
||||
// The literal request body, as a deep-mergeable object. String values
|
||||
// that contain jinja are rendered and spliced as JSON at request time;
|
||||
// literal strings and scalars pass through unchanged.
|
||||
QJsonObject m_body;
|
||||
|
||||
// Roots searched (in order) by the `{% include %}` resolver. The first
|
||||
// is the bundled qrc partials prefix; an optional second is the user
|
||||
// agent's own directory, so user profiles can ship their own partials.
|
||||
std::vector<QString> m_partialRoots;
|
||||
|
||||
// m_env is populated once in fromConfig() and never mutated again.
|
||||
// It is `mutable` only because inja::Environment::render() is not a
|
||||
// const member; m_renderMutex serialises those render() calls since
|
||||
// inja's render path is not internally re-entrant on one Environment.
|
||||
mutable inja::Environment m_env;
|
||||
mutable std::mutex m_renderMutex;
|
||||
};
|
||||
|
||||
@@ -27,15 +27,10 @@ public:
|
||||
PromptTemplate &operator=(PromptTemplate &&) = delete;
|
||||
|
||||
virtual QString name() const = 0;
|
||||
virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0;
|
||||
virtual QString description() const = 0;
|
||||
virtual bool isSupportProvider(ProviderID id) const = 0;
|
||||
|
||||
[[nodiscard]] virtual bool buildFullRequest(
|
||||
QJsonObject &request, const ContextData &context) const
|
||||
{
|
||||
prepareRequest(request, context);
|
||||
return true;
|
||||
}
|
||||
[[nodiscard]] virtual bool buildFullRequest(QJsonObject &request, const ContextData &context) const
|
||||
= 0;
|
||||
};
|
||||
} // namespace QodeAssist::Templates
|
||||
|
||||
@@ -52,11 +52,31 @@ void TomlWriter::writeTableHeader(const QString &name)
|
||||
m_out += QLatin1String("]\n");
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool isBareKey(const QString &key)
|
||||
{
|
||||
if (key.isEmpty())
|
||||
return false;
|
||||
for (QChar c : key) {
|
||||
if (!c.isLetterOrNumber() && c != QLatin1Char('_') && c != QLatin1Char('-'))
|
||||
return false;
|
||||
if (c.unicode() > 0x7f)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void TomlWriter::writeKeyPrefix(const QString &key)
|
||||
{
|
||||
m_out += key;
|
||||
if (m_keyColumnWidth > key.size())
|
||||
m_out += QString(m_keyColumnWidth - key.size(), QLatin1Char(' '));
|
||||
const QString rendered = isBareKey(key)
|
||||
? key
|
||||
: QLatin1Char('"') + escapeBasic(key) + QLatin1Char('"');
|
||||
m_out += rendered;
|
||||
if (m_keyColumnWidth > rendered.size())
|
||||
m_out += QString(m_keyColumnWidth - rendered.size(), QLatin1Char(' '));
|
||||
m_out += QLatin1String(" = ");
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ public:
|
||||
void setKeyColumnWidth(int width) { m_keyColumnWidth = width; }
|
||||
|
||||
void writeBlankLine();
|
||||
void writeComment(const QString &line); // "# line\n"
|
||||
void writeTableHeader(const QString &name); // "[name]\n"
|
||||
void writeComment(const QString &line);
|
||||
void writeTableHeader(const QString &name);
|
||||
|
||||
void writeString(const QString &key, const QString &value);
|
||||
void writeBool(const QString &key, bool value);
|
||||
|
||||
Reference in New Issue
Block a user