Files
zach 1fe8a3a959
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 54s
chore: restructure llm plugin to be more coherent & fix various bugs in llm ui
2026-10-18 12:58:38 +02:00

868 lines
24 KiB
C++

#include "llmclient.hpp"
#include "attachment.hpp"
#include "filetool.hpp"
#include "generation.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include "prompts.hpp"
#include "session.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp"
#include "workspace.hpp"
#include "config/llm.hpp"
#include "config/rootnodes.hpp"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QRegularExpression>
#include <QSet>
#include <QTimer>
#include <QUrl>
namespace ZShell::llm {
namespace {
constexpr int kRefreshIntervalMs = 60'000;
constexpr int kProbeTimeoutMs = 2000;
int contextSizeFromErrorMessage(const QString& message) {
static const QRegularExpression re(
QLatin1String("available context size \\((\\d+)"));
const auto match = re.match(message);
if (match.hasMatch()) return match.captured(1).toInt();
return 0;
}
} // 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::jsonErrorMessage(const QByteArray& body) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (!doc.isObject()) return QString();
const QJsonValue errorValue = doc.object().value("error");
if (errorValue.isObject())
return errorValue.toObject().value("message").toString();
if (errorValue.isString()) return errorValue.toString();
return QString();
}
QString LlmClient::serverErrorMessage(
const QByteArray& body, const QString& fallback) {
const QString message = jsonErrorMessage(body);
if (!message.isEmpty()) return message;
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
: fallback;
}
void LlmClient::setServerError(const QString& value) {
if (m_serverError == value) return;
m_serverError = value;
Q_EMIT serverErrorChanged();
}
void LlmClient::setBusy(bool value) {
if (m_busy == value) return;
m_busy = value;
Q_EMIT busyChanged();
}
void LlmClient::setModel(const QString& value) {
if (m_model == value) return;
m_model = value;
Q_EMIT modelChanged();
if (m_model.isEmpty()) refreshModels();
}
void LlmClient::setContextSize(int size) {
if (size <= 0 || m_tools->contextSize() == size) return;
m_tools->setContextSize(size);
Q_EMIT contextSizeChanged();
}
void LlmClient::setToolsEnabled(bool value) {
if (m_tools->enabled() == value) return;
m_tools->setEnabled(value);
Q_EMIT toolsEnabledChanged();
if (value) refreshAllStates();
}
void LlmClient::setSandboxEnabled(bool value) {
if (m_tools->sandboxEnabled() == value) return;
m_tools->setSandboxEnabled(value);
Q_EMIT sandboxEnabledChanged();
}
LlmClient::LlmClient(QObject* parent) : QObject(parent) {
m_tools = new ToolRegistry(this);
m_store = new ChatStore(this, this);
m_tools->registerTool(new WebFetchTool(m_tools, m_tools));
m_tools->registerTool(new FileReadTool(m_tools, m_tools));
m_tools->registerTool(new ShellTool(m_tools, m_tools));
auto* llm = config::ConfigSingleton::instance()->llm();
if (llm) {
m_endpoint = llm->endpoint();
m_model = llm->model();
m_temperature = llm->temperature();
m_configEnabled = llm->enabled();
m_tools->setEnabled(llm->tools());
m_tools->setSandboxEnabled(llm->sandbox());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
m_endpoint = llm->endpoint();
refreshAllStates();
});
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
setModel(llm->model());
});
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
m_temperature = llm->temperature();
});
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
setToolsEnabled(llm->tools());
});
connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() {
setSandboxEnabled(llm->sandbox());
});
connect(llm, &config::Llm::enabledChanged, this, [this, llm]() {
m_configEnabled = llm->enabled();
if (m_configEnabled) {
m_refreshTimer.setInterval(kRefreshIntervalMs);
m_refreshTimer.start();
refreshAllStates();
} else {
m_refreshTimer.stop();
}
});
}
connect(
m_store, &ChatStore::sessionRemoved, this, [this](ChatSession* session) {
if (m_turn && m_turn->session() == session) {
stop();
endStream();
}
});
m_refreshTimer.setInterval(kRefreshIntervalMs);
connect(
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates);
if (m_configEnabled) {
m_refreshTimer.start();
refreshAllStates();
}
}
LlmClient::~LlmClient() {
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
m_tools->cancelAll();
}
void LlmClient::selectModel(const QString& id) {
if (id.isEmpty()) return;
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_model(id);
setModel(id);
}
ToolContext LlmClient::toolContext() const {
ToolContext context;
if (m_turn) {
context.workspace = m_turn->session()->workspace()->path();
context.outputBudgetChars = m_turn->outputBudgetChars();
}
return context;
}
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target || !m_configEnabled) return;
m_turn = std::make_unique<Turn>(session, target, *m_tools);
if (m_turn->targetRow() < 0) {
fail(QStringLiteral(
"Internal error: generation target is not in the session"));
return;
}
target->setStreaming(true);
setBusy(true);
probeContextSize([this, session, target]() {
if (!m_turn || m_turn->session() != session ||
m_turn->generation() != target)
return;
refreshModels();
sendRound();
});
}
void LlmClient::sendRound() {
if (!m_turn) return;
probeContextSize();
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;
}
Turn* turn = m_turn.get();
QJsonArray messages = turn->contextMessages();
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;
if (m_tools->enabled()) {
const QJsonArray toolSpecs = m_tools->specifications(toolContext());
if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs;
}
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream");
QNetworkReply* reply =
m_manager.post(request, QJsonDocument(body).toJson());
if (!reply) {
fail(QStringLiteral("Failed to start request to LLM server"));
return;
}
m_reply = reply;
connect(reply, &QNetworkReply::readyRead, this, [this, reply, turn]() {
if (m_reply != reply || m_turn.get() != turn) return;
m_buffer.append(reply->readAll());
drainBuffer();
});
connect(reply, &QNetworkReply::finished, this, [this, reply, turn]() {
if (m_reply != reply) {
reply->deleteLater();
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();
if (m_turn.get() != turn) return;
drainBuffer();
if (error == QNetworkReply::NoError) {
if (turn->streamDone) {
roundFinished();
return;
}
const QByteArray tail = m_buffer;
m_buffer.clear();
const QString serverMessage = jsonErrorMessage(tail.simplified());
fail(
serverMessage.isEmpty()
? QStringLiteral(
"LLM server closed the stream before "
"completing the response")
: serverMessage);
return;
}
if (error == QNetworkReply::OperationCanceledError) {
finishTurn();
return;
}
const QString message = serverErrorMessage(responseBody, errorString);
const int reported = contextSizeFromErrorMessage(message);
if (reported > 0 && reported != m_tools->contextSize() &&
!turn->contextRetryUsed) {
qWarning() << "LlmClient: prompt overflow, server reports"
<< reported << "tokens; retrying round";
setContextSize(reported);
turn->contextRetryUsed = true;
sendRound();
return;
}
fail(message);
});
}
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
Turn* turn = m_turn.get();
if (!turn) return;
const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0) return;
while (turn->calls().size() <= index)
turn->calls().append(Turn::Call{});
Turn::Call& builder = turn->calls()[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) {
turn->generation()->closeOpenSegments();
builder.segment =
turn->generation()->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() {
Turn* turn = m_turn.get();
if (!turn || turn->roundDone) return;
turn->roundDone = true;
if (!turn->roundHasCalls()) {
finishTurn();
return;
}
if (turn->finishReason != QLatin1String("tool_calls")) {
qWarning() << "LlmClient: stream ended" << turn->finishReason
<< "with incomplete tool calls, ending turn";
for (const auto& builder : turn->calls()) {
if (!builder.seen || !builder.segment) continue;
builder.segment->finish(
QStringLiteral(
"Stream truncated before the tool call completed"),
false);
}
finishTurn();
return;
}
if (turn->round >= kMaxToolRounds) {
qWarning() << "LlmClient: tool round limit reached, ending turn";
finishTurn();
return;
}
turn->generation()->closeOpenSegments();
turn->appendToTranscript(turn->roundAssistantMessage());
++turn->round;
setApprovalPending(true);
}
void LlmClient::executeAllCalls() {
Turn* turn = m_turn.get();
if (!turn) return;
turn->toolPhase = true;
turn->pendingCalls = 0;
turn->results() = QList<ToolResult>(turn->calls().size());
for (int i = 0; i < turn->calls().size(); ++i) {
const Turn::Call& call = turn->calls().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()) {
turn->results()[i] = {false, errorText};
if (call.segment) call.segment->finish(errorText, false);
continue;
}
ToolContext context = toolContext();
context.toolCallId = call.id;
++turn->pendingCalls;
tool->execute(context, args, [this, turn, i](const ToolResult& result) {
if (m_turn.get() != turn) return;
turn->results()[i] = result;
if (turn->calls().at(i).segment)
turn->calls().at(i).segment->finish(result.text, result.ok);
if (--turn->pendingCalls == 0) flushCallResults();
});
}
if (turn->pendingCalls == 0) flushCallResults();
}
void LlmClient::flushCallResults() {
Turn* turn = m_turn.get();
if (!turn || !turn->toolPhase) return;
turn->toolPhase = false;
for (const QJsonValue& value : turn->toolMessages())
turn->appendToTranscript(value.toObject());
sendRound();
}
void LlmClient::setApprovalPending(bool value) {
Turn* turn = m_turn.get();
if (!turn) return;
if (turn->approvalPending == value) return;
turn->approvalPending = value;
turn->generation()->setApprovalPending(value);
}
void LlmClient::approveTools(ChatGeneration* generation) {
Turn* turn = m_turn.get();
if (!turn || !turn->approvalPending || turn->generation() != generation)
return;
setApprovalPending(false);
for (auto* call : generation->toolCalls()) {
if (call->status() != LlmToolCall::Status::Pending) continue;
call->setStatus(LlmToolCall::Status::Running);
call->begin();
}
executeAllCalls();
}
void LlmClient::denyTools(ChatGeneration* generation) {
Turn* turn = m_turn.get();
if (!turn || !turn->approvalPending || turn->generation() != generation)
return;
setApprovalPending(false);
for (auto* call : generation->toolCalls())
if (call->status() == LlmToolCall::Status::Pending)
call->finish("Denied by user", false);
finishTurn();
}
void LlmClient::finishPendingCalls(const QString& resultText) {
Turn* turn = m_turn.get();
if (!turn) return;
for (auto* call : turn->generation()->toolCalls())
if (call->status() == LlmToolCall::Status::Pending)
call->finish(resultText, false);
}
void LlmClient::stop() {
if (!m_busy) return;
Turn* turn = m_turn.get();
if (!turn) return;
if (turn->approvalPending) {
setApprovalPending(false);
finishPendingCalls(QStringLiteral("Cancelled"));
finishTurn();
return;
}
if (turn->toolPhase) {
m_tools->cancelAll();
for (auto* call : turn->generation()->toolCalls())
if (call->running())
call->finish(QStringLiteral("Cancelled"), false);
finishTurn();
return;
}
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
endStream();
}
void LlmClient::endStream() {
Turn* turn = m_turn.get();
if (!turn) return;
ChatSession* session = turn->session();
ChatGeneration* generation = turn->generation();
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
generation->setStreaming(false);
setApprovalPending(false);
for (auto* call : generation->toolCalls())
if (call->status() == LlmToolCall::Status::Pending)
call->finish(QStringLiteral("Cancelled"), false);
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0 && !generation->error()) {
ChatMessage* message = generation->message();
if (message) {
if (message->generationCount() <= 1)
session->removeMessage(message);
else
message->removeGeneration(generation);
}
}
m_turn.reset();
setBusy(false);
}
void LlmClient::finishTurn() {
ChatSession* session = m_turn ? m_turn->session() : nullptr;
endStream();
if (session) {
m_store->applyPendingClear(session);
session->persist();
}
if (m_configEnabled) refreshAllStates();
}
void LlmClient::fail(const QString& message) {
qWarning() << "LlmClient:" << message;
ChatSession* session = m_turn ? m_turn->session() : nullptr;
if (Turn* turn = m_turn.get()) turn->generation()->setError(message);
finishTurn();
if (session && !message.isEmpty())
Q_EMIT generationFailed(session, 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) {
Turn* turn = m_turn.get();
if (!turn || line.isEmpty() || !line.startsWith("data:")) return;
const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") {
turn->streamDone = true;
roundFinished();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) return;
const QJsonObject obj = doc.object();
updateTokenUsage(obj);
if (obj.contains("error")) {
const QJsonValue errorValue = obj["error"];
QString message;
if (errorValue.isObject())
message = errorValue.toObject()["message"].toString();
else if (errorValue.isString())
message = errorValue.toString();
fail(
message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message);
return;
}
for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
if (!m_turn) break;
const QJsonObject choice = choiceValue.toObject();
const QJsonObject delta = choice["delta"].toObject();
const QString finishReason = choice["finish_reason"].toString();
if (!finishReason.isEmpty()) turn->finishReason = finishReason;
turn->generation()->appendContent(delta["content"].toString());
QString reasoning = delta["reasoning_content"].toString();
if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString();
turn->generation()->appendReasoning(reasoning);
for (const QJsonValue& callValue : delta["tool_calls"].toArray()) {
if (!m_turn) break;
applyToolCallDelta(callValue.toObject());
}
}
}
void LlmClient::refreshAllStates() {
if (!m_configEnabled) return;
if (m_endpoint.trimmed().isEmpty()) {
setServerError(QStringLiteral("LLM endpoint is not configured"));
return;
}
probeContextSize([this]() { refreshModels(); });
}
void LlmClient::refreshModels() {
if (!m_configEnabled) return;
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty()) {
setServerError(QStringLiteral("LLM endpoint is not configured"));
return;
}
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll();
reply->deleteLater();
if (error != QNetworkReply::NoError) {
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
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()) {
setServerError(QStringLiteral(
"LLM server is running but has no models loaded"));
return;
}
setServerError(QString());
m_availableModels = models;
Q_EMIT availableModelsChanged();
if (m_model.isEmpty() || !models.contains(m_model)) {
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_model(models.first());
setModel(models.first());
}
});
}
void LlmClient::probeContextSize(std::function<void()> done) {
if (!m_configEnabled) {
if (done) done();
return;
}
QString base = m_endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) {
setServerError(
QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
if (done) done();
return;
}
auto* reply = m_manager.get(QNetworkRequest(url));
auto settled = std::make_shared<bool>(false);
const auto finish = [this, reply, settled, done]() {
if (*settled) return;
*settled = true;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0;
if (error == QNetworkReply::NoError) {
setServerError(QString());
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);
}
} else {
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
}
if (size > 0)
setContextSize(size);
else if (m_tools->contextSize() <= 0)
setContextSize(4096);
if (done) done();
};
connect(reply, &QNetworkReply::finished, this, finish);
QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() {
if (!*settled) reply->abort();
});
}
void LlmClient::updateTokenUsage(const QJsonObject& data) {
if (!m_turn || m_tools->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) m_turn->session()->setUsageTokens(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();
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();
onResult(result);
});
}
void LlmClient::requestTitle(ChatSession* session, const QString& userText) {
shortRequest(
QStringLiteral("title"),
prompts::titleSystemPrompt(),
userText,
[this, session = QPointer<ChatSession>(session)](QString title) {
if (!session) {
qWarning() << "LlmClient: title request: session gone";
return;
}
title = prompts::cleanTitle(title);
if (title.length() < 2) {
qWarning() << "LlmClient: title rejected (too short)" << title;
return;
}
qInfo() << "LlmClient: suggesting title" << title;
session->setTitle(title);
});
}
void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
const QStringList icons = prompts::iconNames();
shortRequest(
QStringLiteral("icon"),
prompts::iconSystemPrompt(),
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;
session->setIcon(name);
});
}
} // namespace ZShell::llm