Files
z-bar-qt/Plugins/ZShell/Llm/llmclient.cpp
T
zach fcb848b6d2
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 2m18s
C++ / clang-tidy (pull_request) Failing after 2m7s
JS/TS / fmt (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 26s
Rust / clippy (pull_request) Successful in 47s
fix: truncate webfetch content to fit context
2026-09-03 22:46:46 +02:00

950 lines
29 KiB
C++

#include "llmclient.hpp"
#include "generation.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include "segment.hpp"
#include "session.hpp"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSet>
#include <QUrl>
namespace ZShell::llm {
namespace {
// Minimum prompt budget kept even when the transcript alone nearly fills
// the window, so the model always sees the triggering message.
constexpr int kMinContextTokens = 512;
qsizetype jsonValueChars(const QJsonValue& value) {
switch (value.type()) {
case QJsonValue::String:
return value.toString().size();
case QJsonValue::Array: {
qsizetype total = 0;
for (const QJsonValue& item : value.toArray())
total += jsonValueChars(item);
return total;
}
case QJsonValue::Object: {
qsizetype total = 0;
const QJsonObject obj = value.toObject();
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it)
total += jsonValueChars(it.value());
return total;
}
default:
return 2;
}
}
int estimateMessageTokens(const QJsonObject& message) {
return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4;
}
// One message model row and the JSON messages it produces. Trimming works
// in whole units so an assistant turn and its tool results always travel
// together.
struct ContextUnit {
QList<QJsonObject> messages;
int tokens = 0;
};
// Shrink the free-text fields of a unit until it fits the budget. Used
// when a single unit (e.g. one huge user message) alone exceeds it.
void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
static const QStringList kTextKeys = {
QStringLiteral("content"), QStringLiteral("reasoning_content")};
for (int pass = 0; pass < 8; ++pass) {
int tokens = 0;
for (const QJsonObject& message : unit)
tokens += estimateMessageTokens(message);
if (tokens <= budgetTokens) return;
qsizetype bestMessage = -1;
QString bestKey;
qsizetype bestLength = 0;
for (qsizetype i = 0; i < unit.size(); ++i) {
const QJsonObject message = unit.at(i);
for (const QString& key : kTextKeys) {
const QJsonValue value = message.value(key);
if (value.isString() && value.toString().size() > bestLength) {
bestMessage = i;
bestKey = key;
bestLength = value.toString().size();
}
}
}
if (bestMessage < 0) return;
QString text = unit.at(bestMessage).value(bestKey).toString();
text.truncate(qMax<qsizetype>(32, text.size() * 3 / 4));
const QString marker = QStringLiteral("\n[…]");
if (!text.endsWith(marker)) text += marker;
unit[bestMessage][bestKey] = text;
}
}
} // namespace
QString LlmClient::completionsPath(
const QString& endpoint, const QString& subpath) {
QString base = endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
if (!base.endsWith("/v1")) base += "/v1";
return base + subpath;
}
QString LlmClient::serverErrorMessage(
const QByteArray& body, const QString& fallback) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QJsonObject obj = doc.object();
if (obj.contains("error")) {
const QJsonValue errorValue = obj["error"];
if (errorValue.isObject()) {
const QString message =
errorValue.toObject()["message"].toString();
if (!message.isEmpty()) return message;
} else if (!errorValue.toString().isEmpty()) {
return errorValue.toString();
}
}
}
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
: fallback;
}
void LlmClient::setBusy(bool value) {
if (m_busy == value) return;
m_busy = value;
Q_EMIT busyChanged();
}
void LlmClient::setStreamingChatId(const QString& id) {
if (m_streamingChatId == id) return;
m_streamingChatId = id;
Q_EMIT streamingChatIdChanged();
}
LlmClient::LlmClient(QObject* parent) : QObject(parent) {
m_tools = new ToolRegistry(this);
connect(
m_tools,
&ToolRegistry::enabledChanged,
this,
&LlmClient::toolsEnabledChanged);
probeContextSize();
}
LlmClient::~LlmClient() {
if (m_reply) m_reply->abort();
m_tools->cancelAll();
endStream();
}
void LlmClient::setEndpoint(const QString& value) {
if (m_endpoint == value) return;
m_endpoint = value;
Q_EMIT endpointChanged();
probeContextSize();
if (m_model.isEmpty()) refreshModels();
}
void LlmClient::setModel(const QString& value) {
if (m_model == value) return;
m_model = value;
Q_EMIT modelChanged();
if (m_model.isEmpty()) refreshModels();
}
void LlmClient::setTemperature(double value) {
m_temperature = value;
}
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return;
m_active = session;
m_streaming = target;
target->setStreaming(true);
setBusy(true);
setStreamingChatId(session->id());
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) {
fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
return;
}
const auto* model = session->messagesModel();
const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(target->parent()));
if (targetRow < 0) {
fail(QStringLiteral(
"Internal error: generation target is not in the "
"session"));
return;
}
m_transcript = QJsonArray();
m_callBuilders.clear();
m_callResults.clear();
m_finishReason.clear();
m_round = 0;
m_contentMark = 0;
m_reasoningMark = 0;
setApprovalPending(false);
sendRound();
}
void LlmClient::sendRound() {
if (!m_active || !m_streaming) return;
m_finishReason.clear();
m_callBuilders.clear();
m_callResults.clear();
m_roundDone = false;
m_toolPhase = false;
m_pendingCalls = 0;
m_buffer.clear();
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) {
fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
return;
}
ChatSession* active = m_active;
ChatGeneration* streaming = m_streaming;
const auto* model = active->messagesModel();
const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(streaming->parent()));
if (targetRow < 0) {
fail(QStringLiteral(
"Internal error: generation target is not in the "
"session"));
return;
}
// The transcript (this generation so far) is always sent in full, so
// reserve its estimated size out of the context budget.
int transcriptTokens = 0;
for (const QJsonValue& value : m_transcript)
transcriptTokens += estimateMessageTokens(value.toObject());
QJsonArray messages =
buildContextMessages(m_active, targetRow, transcriptTokens);
for (const QJsonValue& value : m_transcript)
messages.append(value);
QJsonObject body;
QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true;
body[QStringLiteral("stream_options")] = streamOptions;
body[QStringLiteral("messages")] = messages;
body[QStringLiteral("stream")] = true;
body[QStringLiteral("temperature")] = m_temperature;
if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model;
const QJsonArray toolSpecs = m_tools->specifications();
if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs;
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream");
m_reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply) m_buffer.append(m_reply->readAll());
drainBuffer();
});
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply;
if (!reply) return;
m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray responseBody = reply->readAll();
m_buffer.append(responseBody);
reply->deleteLater();
drainBuffer();
if (!m_streaming) return;
if (error == QNetworkReply::NoError)
roundFinished();
else if (error == QNetworkReply::OperationCanceledError)
finishTurn();
else
fail(serverErrorMessage(responseBody, errorString));
});
}
QJsonArray LlmClient::buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const {
const auto* model = session->messagesModel();
QList<ContextUnit> units;
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row);
const auto* generation = message->activeGeneration();
if (!generation) continue;
ContextUnit unit;
if (message->role() == ChatMessage::Role::User) {
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content();
unit.messages.append(user);
} else {
QList<const LlmSegment*> toolSegments;
for (const auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall)
toolSegments.append(segment);
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (toolSegments.isEmpty())
assistant[QStringLiteral("content")] = generation->content();
else if (!generation->content().isEmpty())
assistant[QStringLiteral("content")] = generation->content();
if (!generation->reasoning().isEmpty())
assistant[QStringLiteral("reasoning_content")] =
generation->reasoning();
if (!toolSegments.isEmpty()) {
QJsonArray calls;
for (const auto* segment : toolSegments) {
QJsonObject function;
function[QStringLiteral("name")] = segment->name();
function[QStringLiteral("arguments")] =
segment->arguments();
QJsonObject call;
call[QStringLiteral("id")] = segment->toolCallId();
call[QStringLiteral("type")] = QStringLiteral("function");
call[QStringLiteral("function")] = function;
calls.append(call);
}
assistant[QStringLiteral("tool_calls")] = calls;
}
unit.messages.append(assistant);
for (const auto* segment : toolSegments) {
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] =
segment->toolCallId();
toolMessage[QStringLiteral("content")] = segment->result();
unit.messages.append(toolMessage);
}
}
for (const QJsonObject& messageJson : unit.messages)
unit.tokens += estimateMessageTokens(messageJson);
units.append(unit);
}
if (m_contextSize > 0) {
// Keep headroom for the model's own reply so the prompt cannot
// fill the whole window.
const int completionReserve = qBound(1024, m_contextSize / 8, 8192);
int budget = m_contextSize - completionReserve - extraReserveTokens;
budget = qMax(budget, kMinContextTokens);
int total = 0;
for (const ContextUnit& unit : units)
total += unit.tokens;
// Drop oldest units until the prompt fits the budget.
while (units.size() > 1 && total > budget) {
total -= units.first().tokens;
units.removeFirst();
}
// A conversation cannot start with an assistant turn.
while (
units.size() > 1 &&
units.first().messages.first()[QStringLiteral("role")].toString() ==
QLatin1String("assistant")) {
total -= units.first().tokens;
units.removeFirst();
}
// A single oversized unit still gets sent, shrunk to fit.
if (units.size() == 1 && units.first().tokens > budget)
shrinkUnitToBudget(units.first().messages, budget);
}
QJsonArray messages;
for (const ContextUnit& unit : units)
for (const QJsonObject& messageJson : unit.messages)
messages.append(messageJson);
return messages;
}
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0) return;
while (m_callBuilders.size() <= index)
m_callBuilders.append(ToolCallBuilder{});
ToolCallBuilder& builder = m_callBuilders[index];
builder.seen = true;
const QString id = call[QStringLiteral("id")].toString();
if (!id.isEmpty()) builder.id = id;
const QJsonObject function = call[QStringLiteral("function")].toObject();
const QString name = function[QStringLiteral("name")].toString();
if (!name.isEmpty()) builder.name = name;
const QString arguments = function[QStringLiteral("arguments")].toString();
if (!arguments.isEmpty()) builder.arguments += arguments;
if (!builder.segment) {
streaming->closeOpenSegments();
builder.segment = streaming->beginToolCall(builder.name, builder.id);
}
builder.segment->setName(builder.name);
builder.segment->setToolCallId(builder.id);
if (!arguments.isEmpty()) builder.segment->appendArguments(arguments);
}
void LlmClient::roundFinished() {
if (m_roundDone || !m_streaming) return;
ChatGeneration* streaming = m_streaming;
m_roundDone = true;
bool hasCalls = false;
for (const auto& builder : m_callBuilders)
if (builder.seen) hasCalls = true;
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
finishTurn();
return;
}
if (m_round >= kMaxToolRounds) {
qWarning() << "LlmClient: tool round limit reached, ending turn";
finishTurn();
return;
}
streaming->closeOpenSegments();
const QString content = streaming->content();
const QString reasoning = streaming->reasoning();
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (content.size() > m_contentMark) {
assistant[QStringLiteral("content")] = content.mid(m_contentMark);
m_contentMark = content.size();
}
if (reasoning.size() > m_reasoningMark) {
assistant[QStringLiteral("reasoning_content")] =
reasoning.mid(m_reasoningMark);
m_reasoningMark = reasoning.size();
}
QJsonArray calls;
for (const auto& builder : m_callBuilders) {
if (!builder.seen) continue;
QJsonObject function;
function[QStringLiteral("name")] = builder.name;
function[QStringLiteral("arguments")] = builder.arguments;
QJsonObject call;
call[QStringLiteral("id")] = builder.id;
call[QStringLiteral("type")] = QStringLiteral("function");
call[QStringLiteral("function")] = function;
calls.append(call);
}
assistant[QStringLiteral("tool_calls")] = calls;
m_transcript.append(assistant);
m_round++;
// The whole batch of this round waits for a single user decision;
// approveTools() runs all of it, denyTools() ends the turn.
setApprovalPending(true);
}
void LlmClient::executeAllCalls() {
m_toolPhase = true;
m_pendingCalls = 0;
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i);
if (!call.seen) continue;
LlmTool* tool = m_tools->tool(call.name);
QJsonObject args;
QString errorText;
if (!tool) {
errorText =
QStringLiteral("Error: unknown tool '%1'").arg(call.name);
} else if (!call.arguments.isEmpty()) {
const QJsonDocument doc =
QJsonDocument::fromJson(call.arguments.toUtf8());
if (!doc.isObject()) {
errorText = QStringLiteral(
"Error: tool arguments are not valid "
"JSON: %1")
.arg(call.arguments);
} else {
args = doc.object();
}
}
if (!errorText.isEmpty()) {
m_callResults[i] = {errorText, false};
if (LlmSegment* segment = call.segment)
segment->finishTool(errorText, false);
continue;
}
++m_pendingCalls;
tool->execute(call.id, args, [this, i, call](const QJsonObject& result) {
if (!m_streaming) return;
const bool success = result.contains(QStringLiteral("output"));
const QString content =
success ? result[QStringLiteral("output")].toString()
: QStringLiteral("Error: ") +
result[QStringLiteral("error")].toString();
m_callResults[i] = {content, success};
if (LlmSegment* segment = call.segment)
segment->finishTool(content, success);
if (--m_pendingCalls == 0) flushCallResults();
});
}
if (m_pendingCalls == 0) flushCallResults();
}
void LlmClient::flushCallResults() {
if (!m_toolPhase) return;
m_toolPhase = false;
if (!m_streaming) return;
for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i);
if (!call.seen) continue;
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call.id;
toolMessage[QStringLiteral("content")] = m_callResults.at(i).content;
m_transcript.append(toolMessage);
}
sendRound();
}
void LlmClient::setApprovalPending(bool value) {
if (m_approvalPending == value) return;
m_approvalPending = value;
// Mirror onto the streaming generation; QML reads and answers from
// there, so only the affected ProcessBlock reacts.
if (ChatGeneration* streaming = m_streaming)
streaming->setApprovalPending(value);
}
void LlmClient::approveTools() {
if (!m_approvalPending) return;
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
setApprovalPending(false);
for (auto* segment : streaming->segments()) {
if (segment->type() != LlmSegment::Type::ToolCall ||
segment->status() != LlmSegment::Status::Pending)
continue;
// Restart the segment timer so elapsed time covers execution, not
// the wait for approval.
segment->setStatus(LlmSegment::Status::Running);
segment->begin();
}
executeAllCalls();
}
void LlmClient::denyTools() {
if (!m_approvalPending || !m_streaming) return;
setApprovalPending(false);
finishPendingCalls(QStringLiteral("Denied by user"));
finishTurn();
}
void LlmClient::finishPendingCalls(const QString& resultText) {
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
for (auto* segment : streaming->segments())
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
segment->finishTool(resultText, false);
}
void LlmClient::stop() {
if (!m_busy) return;
if (m_approvalPending) {
setApprovalPending(false);
finishPendingCalls(QStringLiteral("Cancelled"));
finishTurn();
return;
}
if (m_toolPhase) {
m_tools->cancelAll();
if (ChatGeneration* streaming = m_streaming) {
for (auto* segment : streaming->segments()) {
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running())
segment->finishTool(QStringLiteral("Cancelled"), false);
}
}
finishTurn();
return;
}
if (m_reply) m_reply->abort();
}
void LlmClient::endStream() {
if (!m_streaming) return;
ChatGeneration* generation = m_streaming;
ChatSession* session = m_active;
m_streaming = nullptr;
m_active = nullptr;
generation->setStreaming(false);
m_approvalPending = false;
generation->setApprovalPending(false);
// Finalize tool calls that never received an approval (turn cancelled,
// round limit reached, ...).
for (auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
segment->finishTool(QStringLiteral("Cancelled"), false);
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) {
if (session) session->removeMessage(message);
} else {
message->removeGeneration(generation);
}
}
}
setBusy(false);
setStreamingChatId(QString());
}
void LlmClient::finishTurn() {
if (!m_streaming) return;
ChatSession* session = m_active;
endStream();
if (session && m_pendingClear == session) {
session->clearMessages();
m_pendingClear.clear();
}
if (session) session->persist();
}
void LlmClient::clearOnFinish(ChatSession* session) {
m_pendingClear = session;
}
void LlmClient::sessionRemoved(ChatSession* session) {
if (m_pendingClear == session) m_pendingClear.clear();
if (m_active == session) {
stop();
endStream();
}
}
void LlmClient::fail(const QString& message) {
qWarning() << "LlmClient:" << message;
finishTurn();
Q_EMIT errorOccurred(message);
}
void LlmClient::drainBuffer() {
while (true) {
const qsizetype newline = m_buffer.indexOf('\n');
if (newline < 0) break;
const QByteArray line = m_buffer.left(newline).trimmed();
m_buffer.remove(0, newline + 1);
handleLine(line);
}
}
void LlmClient::handleLine(const QByteArray& line) {
if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return;
const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") {
roundFinished();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) return;
const QJsonObject obj = doc.object();
updateTokenUsage(obj);
if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString();
fail(
message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message);
return;
}
for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
if (!m_streaming) continue;
const QJsonObject choice = choiceValue.toObject();
const QJsonObject delta = choice["delta"].toObject();
const QString finishReason =
choice[QStringLiteral("finish_reason")].toString();
if (!finishReason.isEmpty()) m_finishReason = finishReason;
m_streaming->appendContent(delta["content"].toString());
QString reasoning = delta["reasoning_content"].toString();
if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString();
m_streaming->appendReasoning(reasoning);
for (const QJsonValue& callValue : delta["tool_calls"].toArray()) {
if (!m_streaming) break;
applyToolCallDelta(callValue.toObject());
}
}
}
void LlmClient::refreshModels() {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty()) return;
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
if (error != QNetworkReply::NoError) {
qWarning() << "LlmClient: failed to fetch models:" << error;
return;
}
const QJsonArray dataArr =
QJsonDocument::fromJson(data).object()["data"].toArray();
QStringList models;
QSet<QString> seen;
for (const QJsonValue& value : dataArr) {
const QString id = value.toObject()["id"].toString();
if (!id.isEmpty() && !seen.contains(id)) {
seen.insert(id);
models.append(id);
}
}
if (models.isEmpty()) return;
m_availableModels = models;
Q_EMIT availableModelsChanged();
if (m_model.isEmpty()) {
m_model = models.first();
Q_EMIT modelChanged();
}
});
}
void LlmClient::setContextSize(int size) {
if (size <= 0 || m_contextSize == size) return;
m_contextSize = size;
m_tools->setContextSize(size);
Q_EMIT contextSizeChanged();
}
void LlmClient::probeContextSize() {
QString base = m_endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) return;
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0;
if (error == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) {
for (const auto& value : doc.array()) {
const QJsonObject slot = value.toObject();
if (slot.contains("n_ctx")) {
size = slot["n_ctx"].toInt(0);
if (size > 0) break;
}
}
} else if (doc.isObject()) {
const QJsonObject obj = doc.object();
size = obj["n_ctx"].toInt(0);
if (size <= 0)
size = obj["default_generation_settings"]
.toObject()["n_ctx"]
.toInt(0);
}
}
setContextSize(size > 0 ? size : 4096);
});
}
void LlmClient::updateTokenUsage(const QJsonObject& data) {
ChatSession* active = m_active;
if (!active || m_contextSize <= 0) return;
const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty()) return;
const double used = usage.value("prompt_tokens").toDouble() +
usage.value("completion_tokens").toDouble();
if (used > 0) active->setLastTokenCount(static_cast<int>(used));
}
void LlmClient::shortRequest(
const QString& tag,
const QString& systemPrompt,
const QString& userText,
std::function<void(QString result)> onResult) {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) return;
qInfo() << "LlmClient:" << tag << "request POST" << url.toString()
<< "model=" << m_model;
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "application/json");
QJsonArray messages;
QJsonObject system;
system[QStringLiteral("role")] = QStringLiteral("system");
system[QStringLiteral("content")] = systemPrompt;
messages.append(system);
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = userText.simplified().mid(0, 512);
messages.append(user);
QJsonObject body;
if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model;
body[QStringLiteral("stream")] = false;
body[QStringLiteral("temperature")] = 0.3;
body[QStringLiteral("max_tokens")] = 128;
QJsonObject templateKwargs;
templateKwargs[QStringLiteral("enable_thinking")] = false;
body[QStringLiteral("chat_template_kwargs")] = templateKwargs;
body[QStringLiteral("messages")] = messages;
auto* reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(
reply,
&QNetworkReply::finished,
this,
[this, reply, tag, onResult = std::move(onResult)]() {
const QByteArray data = reply->readAll();
reply->deleteLater();
qInfo() << "LlmClient:" << tag << "request finished"
<< "error=" << reply->error() << reply->errorString()
<< "http="
<< reply
->attribute(QNetworkRequest::HttpStatusCodeAttribute)
.toInt()
<< "response="
<< QString::fromUtf8(data.left(400)).simplified();
if (reply->error() != QNetworkReply::NoError) return;
const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonArray choices =
doc.object()[QStringLiteral("choices")].toArray();
if (choices.isEmpty()) return;
const QString result = choices.at(0)
.toObject()[QStringLiteral("message")]
.toObject()[QStringLiteral("content")]
.toString()
.trimmed();
qInfo() << "LlmClient:" << tag << "raw result" << result;
onResult(result);
});
}
void LlmClient::requestTitle(ChatSession* session, const QString& userText) {
shortRequest(
QStringLiteral("title"),
QStringLiteral(
"Write a short, concise title for a chat conversation starting "
"with the user's message below. At most six words, no quotation "
"marks, no trailing punctuation. Reply with the title only."),
userText,
[this, session = QPointer<ChatSession>(session)](QString title) {
if (!session) {
qWarning() << "LlmClient: title request: session gone";
return;
}
const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'') ||
c == QChar(u'\u201C') || c == QChar(u'\u201D') ||
c == QChar(u'\u2018') || c == QChar(u'\u2019');
};
while (title.size() >= 2 && isQuote(title.at(0)) &&
isQuote(title.at(title.size() - 1)))
title = title.mid(1, title.size() - 2).simplified();
while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) ||
title.endsWith(QLatin1Char('!')) ||
title.endsWith(QLatin1Char('?'))))
title.chop(1);
if (title.size() < 2) {
qWarning() << "LlmClient: title rejected (too short)" << title;
return;
}
if (title.size() > 48) title = title.left(47) + QStringLiteral("…");
qInfo() << "LlmClient: suggesting title" << title;
Q_EMIT titleSuggested(session, title);
});
}
void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
const QStringList icons = {
"chat", "lightbulb", "code", "description",
"article", "school", "work", "build",
"science", "palette", "music_note", "sports_esports",
"takeout_dining", "flight", "photo_camera", "psychology_alt",
"favorite", "savings", "gamepad", "auto_awesome",
};
const QString prompt =
QStringLiteral(
"Pick the single icon name from this list that best matches the "
"topic of the user's message below: %1. Reply with only the icon "
"name, exactly as written in the list, and nothing else.")
.arg(icons.join(QStringLiteral(", ")));
shortRequest(
QStringLiteral("icon"),
prompt,
userText,
[this, session = QPointer<ChatSession>(session), icons](QString name) {
if (!session) {
qWarning() << "LlmClient: icon request: session gone";
return;
}
name = name.simplified().toLower();
const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'');
};
while (name.size() >= 2 && isQuote(name.at(0)) &&
isQuote(name.at(name.size() - 1)))
name = name.mid(1, name.size() - 2).simplified();
name.replace(QLatin1Char(' '), QLatin1Char('_'));
if (!icons.contains(name)) {
qWarning() << "LlmClient: icon not in list, using default"
<< name;
name = QStringLiteral("chat");
}
qInfo() << "LlmClient: suggesting icon" << name;
Q_EMIT iconSuggested(session, name);
});
}
} // namespace ZShell::llm