better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered
This commit is contained in:
+102
-609
@@ -2,111 +2,119 @@
|
||||
|
||||
#include "config.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "llmclient.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSet>
|
||||
#include <QUrl>
|
||||
|
||||
namespace ZShell {
|
||||
namespace ZShell::llm {
|
||||
|
||||
QString Chat::titleFrom(const QString& content) {
|
||||
const QString flat = content.simplified();
|
||||
if (flat.isEmpty())
|
||||
return QString();
|
||||
if (flat.size() <= 48)
|
||||
return flat;
|
||||
return flat.left(47) + QStringLiteral("…");
|
||||
}
|
||||
|
||||
QString Chat::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 Chat::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 Chat::setBusy(Chat* chat, bool value) {
|
||||
if (chat->m_busy == value)
|
||||
return;
|
||||
chat->m_busy = value;
|
||||
Q_EMIT chat->busyChanged();
|
||||
}
|
||||
|
||||
void Chat::setStreamingChatId(Chat* chat, const QString& id) {
|
||||
if (chat->m_streamingChatId == id)
|
||||
return;
|
||||
chat->m_streamingChatId = id;
|
||||
Q_EMIT chat->streamingChatIdChanged();
|
||||
}
|
||||
|
||||
Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) {
|
||||
Chat::Chat(QObject* parent)
|
||||
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) {
|
||||
if (!config::Config::instance())
|
||||
new config::Config();
|
||||
|
||||
m_store->setLlmClient(m_client);
|
||||
|
||||
const auto* llm = config::Config::instance()->llm();
|
||||
m_endpoint = llm->endpoint();
|
||||
m_temperature = llm->temperature();
|
||||
m_model = llm->model();
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
m_client->setModel(llm->model());
|
||||
m_client->setTemperature(llm->temperature());
|
||||
|
||||
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
|
||||
if (m_endpoint != llm->endpoint()) {
|
||||
m_endpoint = llm->endpoint();
|
||||
Q_EMIT endpointChanged();
|
||||
probeContextSize();
|
||||
}
|
||||
if (m_model.isEmpty())
|
||||
refreshModels();
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
});
|
||||
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
|
||||
if (m_model == llm->model())
|
||||
return;
|
||||
m_model = llm->model();
|
||||
Q_EMIT modelChanged();
|
||||
if (m_model.isEmpty())
|
||||
refreshModels();
|
||||
m_client->setModel(llm->model());
|
||||
});
|
||||
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
|
||||
m_client->setTemperature(llm->temperature());
|
||||
});
|
||||
|
||||
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
||||
// A fresh run supersedes the previous error.
|
||||
if (m_client->busy() && !m_lastError.isEmpty()) {
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
Q_EMIT busyChanged();
|
||||
});
|
||||
connect(
|
||||
llm,
|
||||
&config::Llm::temperatureChanged,
|
||||
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
|
||||
connect(
|
||||
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::availableModelsChanged,
|
||||
this,
|
||||
[this, llm]() { m_temperature = llm->temperature(); });
|
||||
|
||||
if (m_model.isEmpty())
|
||||
refreshModels();
|
||||
probeContextSize();
|
||||
&Chat::availableModelsChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::contextSizeChanged,
|
||||
this,
|
||||
&Chat::contextSizeChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::streamingChatIdChanged,
|
||||
this,
|
||||
&Chat::streamingChatIdChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::errorOccurred,
|
||||
this,
|
||||
[this](const QString& message) {
|
||||
m_lastError = message;
|
||||
Q_EMIT lastErrorChanged();
|
||||
Q_EMIT errorOccurred(message);
|
||||
});
|
||||
connect(
|
||||
m_store,
|
||||
&ChatStore::sessionRemoved,
|
||||
this,
|
||||
[this](ChatSession* session) { m_client->sessionRemoved(session); });
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::titleSuggested,
|
||||
this,
|
||||
[this](ChatSession* session, const QString& title) {
|
||||
qInfo() << "Chat: applying generated title" << session->id()
|
||||
<< title << "(was" << session->title() << ")";
|
||||
session->setTitle(title);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::iconSuggested,
|
||||
this,
|
||||
[this](ChatSession* session, const QString& icon) {
|
||||
qInfo() << "Chat: applying generated icon" << session->id()
|
||||
<< icon << "(was" << session->icon() << ")";
|
||||
session->setIcon(icon);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
}
|
||||
|
||||
Chat::~Chat() {
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
bool Chat::busy() const {
|
||||
return m_client->busy();
|
||||
}
|
||||
|
||||
QString Chat::endpoint() const {
|
||||
return m_client->endpoint();
|
||||
}
|
||||
|
||||
QString Chat::model() const {
|
||||
return m_client->model();
|
||||
}
|
||||
|
||||
QStringList Chat::availableModels() const {
|
||||
return m_client->availableModels();
|
||||
}
|
||||
|
||||
int Chat::contextSize() const {
|
||||
return m_client->contextSize();
|
||||
}
|
||||
|
||||
QString Chat::streamingChatId() const {
|
||||
return m_client->streamingChatId();
|
||||
}
|
||||
|
||||
Chat* Chat::s_instance = nullptr;
|
||||
@@ -117,283 +125,8 @@ Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
void Chat::send(const QString& chatId, const QString& content) {
|
||||
const QString text = content.trimmed();
|
||||
if (text.isEmpty() || m_busy)
|
||||
return;
|
||||
|
||||
ChatSession* session = m_store->sessionById(chatId);
|
||||
if (!session) {
|
||||
qWarning() << "Chat: unknown chat id" << chatId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_lastError.isEmpty()) {
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
|
||||
session->ensureLoaded();
|
||||
session->appendMessage(
|
||||
ChatMessage::Role::User,
|
||||
text,
|
||||
QDateTime::currentMSecsSinceEpoch());
|
||||
while (session->messageCount() > 200)
|
||||
session->removeMessage(session->messages().first());
|
||||
if (session->title().isEmpty()) {
|
||||
session->setTitle(titleFrom(text));
|
||||
qInfo() << "Chat: new session" << chatId
|
||||
<< "fallback title" << session->title()
|
||||
<< "- requesting generated title and icon, model" << m_model
|
||||
<< "endpoint" << m_endpoint;
|
||||
requestTitle(chatId, text);
|
||||
requestIcon(chatId, text);
|
||||
}
|
||||
if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5)
|
||||
trimHistory(session, m_contextSize);
|
||||
|
||||
m_active = session;
|
||||
beginAssistant();
|
||||
m_store->persist(session);
|
||||
}
|
||||
|
||||
void Chat::beginAssistant() {
|
||||
m_streaming = m_active->appendMessage(
|
||||
ChatMessage::Role::Assistant,
|
||||
QString(),
|
||||
QDateTime::currentMSecsSinceEpoch());
|
||||
m_streaming->setStreaming(true);
|
||||
setBusy(this, true);
|
||||
setStreamingChatId(this, m_active->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;
|
||||
}
|
||||
|
||||
QNetworkRequest request(url);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
request.setRawHeader("Accept", "text/event-stream");
|
||||
|
||||
QJsonArray messages;
|
||||
for (const auto* message : m_active->messages()) {
|
||||
if (message == m_streaming)
|
||||
continue;
|
||||
QJsonObject messageObj;
|
||||
messageObj[QStringLiteral("role")] =
|
||||
message->role() == ChatMessage::Role::User
|
||||
? QStringLiteral("user")
|
||||
: QStringLiteral("assistant");
|
||||
messageObj[QStringLiteral("content")] = message->content();
|
||||
if (!message->reasoning().isEmpty())
|
||||
messageObj[QStringLiteral("reasoning_content")] = message->reasoning();
|
||||
messages.append(messageObj);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
m_buffer.clear();
|
||||
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();
|
||||
reply->deleteLater();
|
||||
|
||||
drainBuffer();
|
||||
if (!m_streaming)
|
||||
return;
|
||||
|
||||
if (error == QNetworkReply::NoError ||
|
||||
error == QNetworkReply::OperationCanceledError)
|
||||
finalize();
|
||||
else
|
||||
fail(serverErrorMessage(responseBody, errorString));
|
||||
});
|
||||
}
|
||||
|
||||
void Chat::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() << "Chat:" << 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() << "Chat:" << 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() << "Chat:" << tag << "raw result" << result;
|
||||
onResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
void Chat::requestTitle(const QString& chatId, 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, chatId](QString title) {
|
||||
ChatSession* session = m_store->sessionById(chatId);
|
||||
if (!session) {
|
||||
qWarning() << "Chat: title request: session gone" << chatId;
|
||||
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() << "Chat: title rejected (too short)" << chatId
|
||||
<< title;
|
||||
return;
|
||||
}
|
||||
if (title.size() > 48)
|
||||
title = title.left(47) + QStringLiteral("…");
|
||||
qInfo() << "Chat: applying generated title" << chatId << title
|
||||
<< "(was" << session->title() << ")";
|
||||
session->setTitle(title);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
}
|
||||
|
||||
void Chat::requestIcon(const QString& chatId, 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, chatId, icons](QString name) {
|
||||
ChatSession* session = m_store->sessionById(chatId);
|
||||
if (!session) {
|
||||
qWarning() << "Chat: icon request: session gone" << chatId;
|
||||
return;
|
||||
}
|
||||
name = name.simplified().toLower();
|
||||
while (name.size() >= 2 &&
|
||||
(name.at(0) == QLatin1Char('"') ||
|
||||
name.at(0) == QLatin1Char('\'')))
|
||||
name = name.mid(1).left(name.size() - 2).simplified();
|
||||
name.replace(QLatin1Char(' '), QLatin1Char('_'));
|
||||
if (!icons.contains(name)) {
|
||||
qWarning() << "Chat: icon not in list, using default" << chatId
|
||||
<< name;
|
||||
name = QStringLiteral("chat");
|
||||
}
|
||||
qInfo() << "Chat: applying generated icon" << chatId << name
|
||||
<< "(was" << session->icon() << ")";
|
||||
session->setIcon(name);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
}
|
||||
|
||||
void Chat::stop() {
|
||||
if (!m_busy)
|
||||
return;
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
m_client->stop();
|
||||
}
|
||||
|
||||
void Chat::dismissError() {
|
||||
@@ -403,256 +136,16 @@ void Chat::dismissError() {
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
|
||||
void Chat::clearConversation(const QString& chatId) {
|
||||
ChatSession* session = m_store->sessionById(chatId);
|
||||
if (!session)
|
||||
return;
|
||||
if (m_busy && m_active == session) {
|
||||
m_pendingClearChatId = chatId;
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
session->ensureLoaded();
|
||||
session->clearMessages();
|
||||
m_store->persist(session);
|
||||
}
|
||||
|
||||
void Chat::finalize() {
|
||||
if (!m_streaming)
|
||||
return;
|
||||
|
||||
ChatSession* session = m_active;
|
||||
endStream();
|
||||
if (session && m_pendingClearChatId == session->id()) {
|
||||
session->clearMessages();
|
||||
m_pendingClearChatId.clear();
|
||||
}
|
||||
if (session)
|
||||
m_store->persist(session);
|
||||
}
|
||||
|
||||
void Chat::endStream() {
|
||||
if (!m_streaming)
|
||||
return;
|
||||
m_streaming->setStreaming(false);
|
||||
if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active)
|
||||
m_active->removeMessage(m_streaming);
|
||||
m_streaming = nullptr;
|
||||
m_active = nullptr;
|
||||
setBusy(this, false);
|
||||
setStreamingChatId(this, QString());
|
||||
}
|
||||
|
||||
void Chat::fail(const QString& message) {
|
||||
qWarning() << "Chat:" << message;
|
||||
|
||||
const bool overflow = message.contains("overflow") ||
|
||||
message.contains("exceed") ||
|
||||
m_lastTokenCount > m_contextSize;
|
||||
const QString shown = overflow
|
||||
? QStringLiteral(
|
||||
"%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed "
|
||||
"so the next one will fit)")
|
||||
.arg(message, QString::number(m_lastTokenCount),
|
||||
QString::number(m_contextSize))
|
||||
: message;
|
||||
m_lastError = shown;
|
||||
Q_EMIT lastErrorChanged();
|
||||
|
||||
if (m_streaming) {
|
||||
ChatSession* session = m_active;
|
||||
endStream();
|
||||
if (overflow)
|
||||
trimHistory(session, m_contextSize);
|
||||
if (session && m_pendingClearChatId == session->id()) {
|
||||
session->clearMessages();
|
||||
m_pendingClearChatId.clear();
|
||||
}
|
||||
if (session)
|
||||
m_store->persist(session);
|
||||
}
|
||||
Q_EMIT errorOccurred(shown);
|
||||
}
|
||||
|
||||
void Chat::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 Chat::handleLine(const QByteArray& line) {
|
||||
if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:"))
|
||||
return;
|
||||
|
||||
const QByteArray data = line.mid(5).trimmed();
|
||||
if (data == "[DONE]") {
|
||||
finalize();
|
||||
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()) {
|
||||
const QJsonObject delta = choiceValue.toObject()["delta"].toObject();
|
||||
if (!m_streaming)
|
||||
continue;
|
||||
m_streaming->appendContent(delta["content"].toString());
|
||||
QString reasoning = delta["reasoning_content"].toString();
|
||||
if (reasoning.isEmpty())
|
||||
reasoning = delta["reasoning"].toString();
|
||||
m_streaming->appendReasoning(reasoning);
|
||||
}
|
||||
}
|
||||
|
||||
void Chat::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() << "Chat: 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();
|
||||
}
|
||||
});
|
||||
m_client->refreshModels();
|
||||
}
|
||||
|
||||
void Chat::selectModel(const QString& id) {
|
||||
if (id.isEmpty())
|
||||
return;
|
||||
m_model = id;
|
||||
Q_EMIT modelChanged();
|
||||
config::Config::instance()->llm()->set_model(id);
|
||||
m_client->setModel(id);
|
||||
if (auto* config = config::Config::instance())
|
||||
config->llm()->set_model(id);
|
||||
}
|
||||
|
||||
void Chat::setContextSize(int size) {
|
||||
if (size <= 0 || m_contextSize == size)
|
||||
return;
|
||||
m_contextSize = size;
|
||||
Q_EMIT contextSizeChanged();
|
||||
}
|
||||
|
||||
void Chat::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 Chat::updateTokenUsage(const QJsonObject& data) {
|
||||
if (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)
|
||||
m_lastTokenCount = qMin(static_cast<int>(used), m_contextSize * 2);
|
||||
}
|
||||
|
||||
void Chat::trimHistory(ChatSession* session, int contextSize) {
|
||||
if (!session || contextSize <= 0)
|
||||
return;
|
||||
const int budget = contextSize * 4 / 5;
|
||||
auto estimate = [&](const ChatMessage* message) -> qsizetype {
|
||||
return (message->content().size() +
|
||||
message->reasoning().size()) /
|
||||
4;
|
||||
};
|
||||
qsizetype total = 0;
|
||||
for (const auto* message : session->messages())
|
||||
total += estimate(message);
|
||||
while (total > budget && session->messageCount() >= 2) {
|
||||
ChatMessage* first = session->messages().first();
|
||||
const qsizetype used = estimate(first);
|
||||
session->removeMessage(first);
|
||||
total -= used;
|
||||
if (session->messageCount() >= 2 &&
|
||||
session->messages().first()->role() == ChatMessage::Role::Assistant) {
|
||||
ChatMessage* second = session->messages().first();
|
||||
const qsizetype usedSecond = estimate(second);
|
||||
session->removeMessage(second);
|
||||
total -= usedSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ZShell
|
||||
} // namespace ZShell::llm
|
||||
|
||||
Reference in New Issue
Block a user