fix: truncate webfetch content to fit context
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
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
This commit is contained in:
@@ -288,6 +288,7 @@ qml_module(ZShell-llm
|
||||
chat.hpp chat.cpp
|
||||
chatstore.hpp chatstore.cpp
|
||||
codehighlighter.hpp codehighlighter.cpp
|
||||
filetool.hpp filetool.cpp
|
||||
generation.hpp generation.cpp
|
||||
llmclient.hpp llmclient.cpp
|
||||
markdownblock.hpp
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "chat.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
#include "filetool.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "llmclient.hpp"
|
||||
#include "webfetchtool.hpp"
|
||||
@@ -17,6 +18,7 @@ Chat::Chat(QObject* parent)
|
||||
|
||||
m_store->setLlmClient(m_client);
|
||||
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
||||
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
|
||||
|
||||
const auto* llm = config::Config::instance()->llm();
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
@@ -137,6 +139,7 @@ QString Chat::streamingChatId() const {
|
||||
return m_client->streamingChatId();
|
||||
}
|
||||
|
||||
|
||||
Chat* Chat::s_instance = nullptr;
|
||||
|
||||
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#include "filetool.hpp"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
namespace {
|
||||
|
||||
QJsonObject makeOutput(const QString& text) {
|
||||
QJsonObject obj;
|
||||
obj[QStringLiteral("output")] = text;
|
||||
return obj;
|
||||
}
|
||||
|
||||
QJsonObject makeError(const QString& message) {
|
||||
QJsonObject obj;
|
||||
obj[QStringLiteral("error")] = message;
|
||||
return obj;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
|
||||
|
||||
QString FileReadTool::name() const {
|
||||
return QStringLiteral("readfile");
|
||||
}
|
||||
|
||||
QString FileReadTool::description() const {
|
||||
return QStringLiteral(
|
||||
"Read a text file from the local filesystem and return part of "
|
||||
"its content. At most a limited number of characters is returned "
|
||||
"per call; use offset and limit to page through larger files. "
|
||||
"Binary files are not supported. Full output of webfetch calls "
|
||||
"is stored under /tmp/zshell-llm/webfetch/ and can be read this "
|
||||
"way. This tool is read-only.");
|
||||
}
|
||||
|
||||
QJsonObject FileReadTool::parameters() const {
|
||||
QJsonObject path;
|
||||
path[QStringLiteral("type")] = QStringLiteral("string");
|
||||
path[QStringLiteral("description")] =
|
||||
QStringLiteral("Path of the file to read");
|
||||
|
||||
QJsonObject offset;
|
||||
offset[QStringLiteral("type")] = QStringLiteral("integer");
|
||||
offset[QStringLiteral("minimum")] = 0;
|
||||
offset[QStringLiteral("description")] =
|
||||
QStringLiteral("Byte offset to start reading from. Defaults to 0.");
|
||||
|
||||
QJsonObject limit;
|
||||
limit[QStringLiteral("type")] = QStringLiteral("integer");
|
||||
limit[QStringLiteral("minimum")] = 1;
|
||||
limit[QStringLiteral("description")] =
|
||||
QStringLiteral("Maximum number of characters to return. Defaults "
|
||||
"to a value that fits the model's context window.");
|
||||
|
||||
QJsonObject properties;
|
||||
properties[QStringLiteral("path")] = path;
|
||||
properties[QStringLiteral("offset")] = offset;
|
||||
properties[QStringLiteral("limit")] = limit;
|
||||
|
||||
QJsonObject schema;
|
||||
schema[QStringLiteral("type")] = QStringLiteral("object");
|
||||
schema[QStringLiteral("properties")] = properties;
|
||||
QJsonArray required;
|
||||
required.append(QStringLiteral("path"));
|
||||
schema[QStringLiteral("required")] = required;
|
||||
return schema;
|
||||
}
|
||||
|
||||
void FileReadTool::execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
std::function<void(const QJsonObject&)> done) {
|
||||
Q_UNUSED(toolCallId);
|
||||
const QString path = args[QStringLiteral("path")].toString().trimmed();
|
||||
if (path.isEmpty()) {
|
||||
done(makeError(QStringLiteral("Missing required argument 'path'")));
|
||||
return;
|
||||
}
|
||||
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
|
||||
const int budget = inlineBudgetChars();
|
||||
const int limit =
|
||||
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
const QFileInfo info(path);
|
||||
if (info.exists() && info.isDir()) {
|
||||
done(makeError(QStringLiteral("Path is a directory, not a "
|
||||
"file: %1")
|
||||
.arg(path)));
|
||||
} else {
|
||||
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const qint64 fileSize = file.size();
|
||||
if (fileSize == 0) {
|
||||
file.close();
|
||||
done(makeOutput(QString()));
|
||||
return;
|
||||
}
|
||||
if (offset >= fileSize) {
|
||||
file.close();
|
||||
done(makeError(QStringLiteral("Offset %1 is beyond the end of the "
|
||||
"file (%2 bytes)")
|
||||
.arg(offset)
|
||||
.arg(fileSize)));
|
||||
return;
|
||||
}
|
||||
if (!file.seek(offset)) {
|
||||
file.close();
|
||||
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
|
||||
return;
|
||||
}
|
||||
// UTF-8 uses at most 4 bytes per character, so this many bytes always
|
||||
// cover `limit` characters.
|
||||
const qint64 wanted = qint64(limit) * 4 + 16;
|
||||
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
|
||||
file.close();
|
||||
|
||||
if (chunk.contains('\0')) {
|
||||
done(makeError(QStringLiteral("Binary files are not supported: %1")
|
||||
.arg(path)));
|
||||
return;
|
||||
}
|
||||
const QString content = QString::fromUtf8(chunk);
|
||||
if (content.size() <= limit) {
|
||||
done(makeOutput(content));
|
||||
return;
|
||||
}
|
||||
const QString head = content.left(limit);
|
||||
const int nextOffset = offset + head.toUtf8().size();
|
||||
const QString output =
|
||||
head +
|
||||
QStringLiteral(
|
||||
"\n\n[... truncated: showing %1 characters (bytes %2-%3 of a "
|
||||
"%4 byte file). Continue with offset=%5.]")
|
||||
.arg(limit)
|
||||
.arg(offset)
|
||||
.arg(nextOffset)
|
||||
.arg(fileSize)
|
||||
.arg(nextOffset);
|
||||
done(makeOutput(output));
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "tool.hpp"
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
// Lets the model read text files from the local filesystem, in
|
||||
// byte-offset windows small enough to fit the context. Primarily used to
|
||||
// page through the full webfetch output stored under
|
||||
// /tmp/zshell-llm/webfetch/.
|
||||
class FileReadTool : public LlmTool {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit FileReadTool(QObject* parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
QString description() const override;
|
||||
QJsonObject parameters() const override;
|
||||
void execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
std::function<void(const QJsonObject& result)> done) override;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "generation.hpp"
|
||||
|
||||
#include "llmclient.hpp"
|
||||
#include "messagemodel.hpp"
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
namespace ZShell::llm {
|
||||
@@ -46,7 +50,7 @@ QString ChatGeneration::reasoning() const {
|
||||
bool ChatGeneration::reasoningActive() const {
|
||||
if (!m_streaming) return false;
|
||||
if (!content().isEmpty()) return false;
|
||||
return !hasRunningTool();
|
||||
return !hasRunningTool() && !hasPendingTool();
|
||||
}
|
||||
|
||||
qint64 ChatGeneration::reasoningElapsedMs() const {
|
||||
@@ -87,6 +91,45 @@ bool ChatGeneration::hasRunningTool() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatGeneration::hasPendingTool() const {
|
||||
for (const auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||
segment->status() == LlmSegment::Status::Pending)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void ChatGeneration::setApprovalPending(bool value) {
|
||||
if (m_approvalPending == value) return;
|
||||
m_approvalPending = value;
|
||||
Q_EMIT toolApprovalPendingChanged();
|
||||
}
|
||||
|
||||
QVariantList ChatGeneration::pendingToolCalls() const {
|
||||
QVariantList out;
|
||||
if (!m_approvalPending) return out;
|
||||
for (const auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||
segment->status() == LlmSegment::Status::Pending)
|
||||
out.append(QVariant::fromValue(segment));
|
||||
return out;
|
||||
}
|
||||
|
||||
void ChatGeneration::approveTools() {
|
||||
if (auto* llmClient = client()) llmClient->approveTools();
|
||||
}
|
||||
|
||||
void ChatGeneration::denyTools() {
|
||||
if (auto* llmClient = client()) llmClient->denyTools();
|
||||
}
|
||||
|
||||
LlmClient* ChatGeneration::client() const {
|
||||
if (auto* message = qobject_cast<ChatMessage*>(parent()))
|
||||
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
|
||||
if (auto* session = model->session()) return session->client();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ChatGeneration::updateReasoningActive() {
|
||||
const bool active = reasoningActive();
|
||||
if (m_reasoningActive == active) return;
|
||||
@@ -177,7 +220,9 @@ LlmSegment* ChatGeneration::beginToolCall(
|
||||
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
|
||||
segment->setName(name);
|
||||
segment->setToolCallId(toolCallId);
|
||||
segment->setStatus(LlmSegment::Status::Running);
|
||||
// Awaiting user approval until LlmClient::approveTools() promotes it
|
||||
// to Running; denyTools()/endStream() finalize it as an error.
|
||||
segment->setStatus(LlmSegment::Status::Pending);
|
||||
segment->begin();
|
||||
addSegment(segment);
|
||||
Q_EMIT toolStateChanged();
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
#include <QVariantList>
|
||||
#include <QtQml>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class LlmClient;
|
||||
|
||||
class ChatGeneration : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
@@ -33,6 +36,12 @@ class ChatGeneration : public QObject {
|
||||
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
|
||||
segmentsChanged)
|
||||
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
|
||||
Q_PROPERTY(
|
||||
bool toolApprovalPending READ toolApprovalPending NOTIFY
|
||||
toolApprovalPendingChanged)
|
||||
Q_PROPERTY(
|
||||
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
|
||||
toolApprovalPendingChanged)
|
||||
|
||||
public:
|
||||
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
|
||||
@@ -48,6 +57,14 @@ class ChatGeneration : public QObject {
|
||||
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
|
||||
[[nodiscard]] int toolCallCount() const;
|
||||
[[nodiscard]] bool hasRunningTool() const;
|
||||
[[nodiscard]] bool hasPendingTool() const;
|
||||
|
||||
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
|
||||
void setApprovalPending(bool value);
|
||||
[[nodiscard]] QVariantList pendingToolCalls() const;
|
||||
|
||||
Q_INVOKABLE void approveTools();
|
||||
Q_INVOKABLE void denyTools();
|
||||
|
||||
void setContent(const QString& value);
|
||||
void appendContent(const QString& piece);
|
||||
@@ -69,14 +86,17 @@ class ChatGeneration : public QObject {
|
||||
void streamingChanged();
|
||||
void toolStateChanged();
|
||||
void segmentsChanged();
|
||||
void toolApprovalPendingChanged();
|
||||
|
||||
private:
|
||||
void updateReasoningActive();
|
||||
LlmClient* client() const;
|
||||
|
||||
QTimer m_timer;
|
||||
QList<LlmSegment*> m_segments;
|
||||
bool m_reasoningActive = false;
|
||||
bool m_streaming = false;
|
||||
bool m_approvalPending = false;
|
||||
qint64 m_timestamp;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,81 @@
|
||||
|
||||
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();
|
||||
@@ -97,7 +172,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
||||
if (m_busy || !session || !target) return;
|
||||
m_active = session;
|
||||
m_streaming = target;
|
||||
m_streaming->setStreaming(true);
|
||||
target->setStreaming(true);
|
||||
setBusy(true);
|
||||
setStreamingChatId(session->id());
|
||||
|
||||
@@ -125,6 +200,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
||||
m_round = 0;
|
||||
m_contentMark = 0;
|
||||
m_reasoningMark = 0;
|
||||
setApprovalPending(false);
|
||||
|
||||
sendRound();
|
||||
}
|
||||
@@ -146,9 +222,11 @@ void LlmClient::sendRound() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto* model = m_active->messagesModel();
|
||||
ChatSession* active = m_active;
|
||||
ChatGeneration* streaming = m_streaming;
|
||||
const auto* model = active->messagesModel();
|
||||
const int targetRow =
|
||||
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent()));
|
||||
model->rowOf(qobject_cast<ChatMessage*>(streaming->parent()));
|
||||
if (targetRow < 0) {
|
||||
fail(QStringLiteral(
|
||||
"Internal error: generation target is not in the "
|
||||
@@ -156,7 +234,13 @@ void LlmClient::sendRound() {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray messages = buildContextMessages(m_active, targetRow);
|
||||
// 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);
|
||||
|
||||
@@ -205,67 +289,103 @@ void LlmClient::sendRound() {
|
||||
}
|
||||
|
||||
QJsonArray LlmClient::buildContextMessages(
|
||||
ChatSession* session, int stopBeforeRow) const {
|
||||
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const {
|
||||
const auto* model = session->messagesModel();
|
||||
QJsonArray messages;
|
||||
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();
|
||||
messages.append(user);
|
||||
continue;
|
||||
}
|
||||
unit.messages.append(user);
|
||||
} else {
|
||||
QList<const LlmSegment*> toolSegments;
|
||||
for (const auto* segment : generation->segments())
|
||||
if (segment->type() == LlmSegment::Type::ToolCall)
|
||||
toolSegments.append(segment);
|
||||
|
||||
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()) {
|
||||
messages.append(assistant);
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
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();
|
||||
messages.append(toolMessage);
|
||||
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) {
|
||||
if (!m_streaming) return;
|
||||
ChatGeneration* streaming = m_streaming;
|
||||
if (!streaming) return;
|
||||
const int index = call[QStringLiteral("index")].toInt(-1);
|
||||
if (index < 0) return;
|
||||
while (m_callBuilders.size() <= index)
|
||||
@@ -281,8 +401,8 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
||||
if (!arguments.isEmpty()) builder.arguments += arguments;
|
||||
|
||||
if (!builder.segment) {
|
||||
m_streaming->closeOpenSegments();
|
||||
builder.segment = m_streaming->beginToolCall(builder.name, builder.id);
|
||||
streaming->closeOpenSegments();
|
||||
builder.segment = streaming->beginToolCall(builder.name, builder.id);
|
||||
}
|
||||
builder.segment->setName(builder.name);
|
||||
builder.segment->setToolCallId(builder.id);
|
||||
@@ -291,6 +411,7 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
||||
|
||||
void LlmClient::roundFinished() {
|
||||
if (m_roundDone || !m_streaming) return;
|
||||
ChatGeneration* streaming = m_streaming;
|
||||
m_roundDone = true;
|
||||
|
||||
bool hasCalls = false;
|
||||
@@ -306,10 +427,10 @@ void LlmClient::roundFinished() {
|
||||
return;
|
||||
}
|
||||
|
||||
m_streaming->closeOpenSegments();
|
||||
streaming->closeOpenSegments();
|
||||
|
||||
const QString content = m_streaming->content();
|
||||
const QString reasoning = m_streaming->reasoning();
|
||||
const QString content = streaming->content();
|
||||
const QString reasoning = streaming->reasoning();
|
||||
QJsonObject assistant;
|
||||
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
|
||||
if (content.size() > m_contentMark) {
|
||||
@@ -337,7 +458,9 @@ void LlmClient::roundFinished() {
|
||||
m_transcript.append(assistant);
|
||||
m_round++;
|
||||
|
||||
executeAllCalls();
|
||||
// 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() {
|
||||
@@ -375,7 +498,7 @@ void LlmClient::executeAllCalls() {
|
||||
}
|
||||
|
||||
++m_pendingCalls;
|
||||
tool->execute(args, [this, i, call](const QJsonObject& result) {
|
||||
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 =
|
||||
@@ -407,12 +530,60 @@ void LlmClient::flushCallResults() {
|
||||
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 (m_streaming) {
|
||||
for (auto* segment : m_streaming->segments()) {
|
||||
if (ChatGeneration* streaming = m_streaming) {
|
||||
for (auto* segment : streaming->segments()) {
|
||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||
segment->running())
|
||||
segment->finishTool(QStringLiteral("Cancelled"), false);
|
||||
@@ -426,11 +597,19 @@ void LlmClient::stop() {
|
||||
|
||||
void LlmClient::endStream() {
|
||||
if (!m_streaming) return;
|
||||
auto* generation = m_streaming;
|
||||
auto* session = m_active;
|
||||
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())) {
|
||||
@@ -570,6 +749,7 @@ void LlmClient::refreshModels() {
|
||||
void LlmClient::setContextSize(int size) {
|
||||
if (size <= 0 || m_contextSize == size) return;
|
||||
m_contextSize = size;
|
||||
m_tools->setContextSize(size);
|
||||
Q_EMIT contextSizeChanged();
|
||||
}
|
||||
|
||||
@@ -611,12 +791,13 @@ void LlmClient::probeContextSize() {
|
||||
}
|
||||
|
||||
void LlmClient::updateTokenUsage(const QJsonObject& data) {
|
||||
if (!m_active || m_contextSize <= 0) return;
|
||||
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) m_active->setLastTokenCount(static_cast<int>(used));
|
||||
if (used > 0) active->setLastTokenCount(static_cast<int>(used));
|
||||
}
|
||||
|
||||
void LlmClient::shortRequest(
|
||||
|
||||
@@ -56,6 +56,13 @@ class LlmClient : public QObject {
|
||||
void clearOnFinish(ChatSession* session);
|
||||
void sessionRemoved(ChatSession* session);
|
||||
|
||||
// Tool calls are executed only after the user approves the batch the
|
||||
// model requested. The per-generation state (toolApprovalPending,
|
||||
// pendingToolCalls) is mirrored onto ChatGeneration; these are the
|
||||
// entry points QML reaches through it.
|
||||
void approveTools();
|
||||
void denyTools();
|
||||
|
||||
void refreshModels();
|
||||
void requestTitle(ChatSession* session, const QString& userText);
|
||||
void requestIcon(ChatSession* session, const QString& userText);
|
||||
@@ -83,11 +90,13 @@ class LlmClient : public QObject {
|
||||
|
||||
void sendRound();
|
||||
QJsonArray buildContextMessages(
|
||||
ChatSession* session, int stopBeforeRow) const;
|
||||
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
|
||||
void applyToolCallDelta(const QJsonObject& call);
|
||||
void roundFinished();
|
||||
void executeAllCalls();
|
||||
void flushCallResults();
|
||||
void setApprovalPending(bool value);
|
||||
void finishPendingCalls(const QString& resultText);
|
||||
void finishTurn();
|
||||
void fail(const QString& message);
|
||||
void handleLine(const QByteArray& line);
|
||||
@@ -109,8 +118,11 @@ class LlmClient : public QObject {
|
||||
ToolRegistry* m_tools = nullptr;
|
||||
QNetworkReply* m_reply = nullptr;
|
||||
QByteArray m_buffer;
|
||||
ChatSession* m_active = nullptr;
|
||||
ChatGeneration* m_streaming = nullptr;
|
||||
// QPointer: these objects are owned by the ChatStore tree, which Qt
|
||||
// destroys *before* this client on shutdown (children die in creation
|
||||
// order). The pointers must self-null instead of dangling.
|
||||
QPointer<ChatSession> m_active;
|
||||
QPointer<ChatGeneration> m_streaming;
|
||||
QPointer<ChatSession> m_pendingClear;
|
||||
bool m_busy = false;
|
||||
QString m_streamingChatId;
|
||||
@@ -133,6 +145,7 @@ class LlmClient : public QObject {
|
||||
qsizetype m_reasoningMark = 0;
|
||||
bool m_roundDone = false;
|
||||
bool m_toolPhase = false;
|
||||
bool m_approvalPending = false;
|
||||
int m_pendingCalls = 0;
|
||||
static constexpr int kMaxToolRounds = 12;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,9 @@ class LlmSegment : public QObject {
|
||||
enum class Type : int { Reasoning = 0, ToolCall, Content };
|
||||
Q_ENUM(Type)
|
||||
|
||||
enum class Status : int { None = 0, Running, Success, Error };
|
||||
// Pending is appended last: statuses are persisted as integers in the
|
||||
// chat database, so existing values must not move.
|
||||
enum class Status : int { None = 0, Running, Success, Error, Pending };
|
||||
Q_ENUM(Status)
|
||||
|
||||
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
|
||||
|
||||
@@ -6,6 +6,20 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
|
||||
|
||||
LlmTool::~LlmTool() = default;
|
||||
|
||||
void LlmTool::setContextSize(int value) {
|
||||
if (m_contextSize == value) return;
|
||||
m_contextSize = value;
|
||||
}
|
||||
|
||||
int LlmTool::inlineBudgetChars() const {
|
||||
if (m_contextSize <= 0) return DefaultInlineChars;
|
||||
// About a quarter of the context window, clamped so tiny contexts
|
||||
// still get a usable result and huge ones stay sane.
|
||||
const int tokens =
|
||||
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
|
||||
return tokens * CharsPerToken;
|
||||
}
|
||||
|
||||
void LlmTool::cancel() {}
|
||||
|
||||
QJsonObject LlmTool::specification() const {
|
||||
@@ -30,6 +44,7 @@ void ToolRegistry::setEnabled(bool value) {
|
||||
void ToolRegistry::registerTool(LlmTool* tool) {
|
||||
if (!tool || m_tools.contains(tool)) return;
|
||||
tool->setParent(this);
|
||||
tool->setContextSize(m_contextSize);
|
||||
m_tools.append(tool);
|
||||
}
|
||||
|
||||
@@ -52,4 +67,11 @@ void ToolRegistry::cancelAll() {
|
||||
tool->cancel();
|
||||
}
|
||||
|
||||
void ToolRegistry::setContextSize(int value) {
|
||||
if (m_contextSize == value) return;
|
||||
m_contextSize = value;
|
||||
for (auto* tool : m_tools)
|
||||
tool->setContextSize(value);
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -21,12 +21,33 @@ class LlmTool : public QObject {
|
||||
[[nodiscard]] virtual QString description() const = 0;
|
||||
[[nodiscard]] virtual QJsonObject parameters() const = 0;
|
||||
|
||||
// toolCallId identifies the call within the conversation; tools may use
|
||||
// it to name files they write for later retrieval.
|
||||
virtual void execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
std::function<void(const QJsonObject& result)> done) = 0;
|
||||
virtual void cancel();
|
||||
|
||||
[[nodiscard]] QJsonObject specification() const;
|
||||
|
||||
// The endpoint's context size in tokens (0 = unknown). Set by LlmClient.
|
||||
[[nodiscard]] int contextSize() const { return m_contextSize; }
|
||||
void setContextSize(int value);
|
||||
|
||||
// Rough cap, in characters, for tool output embedded in a tool result
|
||||
// message. Scales with the context size so a single result cannot
|
||||
// consume the whole window.
|
||||
[[nodiscard]] int inlineBudgetChars() const;
|
||||
|
||||
// ~4 characters per token; a deliberately coarse estimate used for
|
||||
// budgeting (never for exact accounting).
|
||||
static constexpr int CharsPerToken = 4;
|
||||
// Inline budget when the context size is unknown.
|
||||
static constexpr int DefaultInlineChars = 64 * 1024;
|
||||
|
||||
protected:
|
||||
int m_contextSize = 0;
|
||||
};
|
||||
|
||||
class ToolRegistry : public QObject {
|
||||
@@ -45,11 +66,15 @@ class ToolRegistry : public QObject {
|
||||
[[nodiscard]] QJsonArray specifications() const;
|
||||
void cancelAll();
|
||||
|
||||
// Propagated to every registered tool, including later ones.
|
||||
void setContextSize(int value);
|
||||
|
||||
Q_SIGNALS:
|
||||
void enabledChanged();
|
||||
|
||||
private:
|
||||
bool m_enabled = true;
|
||||
int m_contextSize = 0;
|
||||
QList<LlmTool*> m_tools;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "webfetchtool.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
@@ -8,6 +10,9 @@
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
const QString WebFetchTool::StoragePath =
|
||||
QStringLiteral("/tmp/zshell-llm/webfetch");
|
||||
|
||||
namespace {
|
||||
|
||||
const char* kUserAgent =
|
||||
@@ -46,7 +51,10 @@ QString WebFetchTool::description() const {
|
||||
return QStringLiteral(
|
||||
"Fetch content from an HTTP or HTTPS URL and return it as plain "
|
||||
"text or raw HTML. HTML pages are reduced to their visible text "
|
||||
"by default. This tool is read-only.");
|
||||
"by default. Only a limited amount of content is returned inline; "
|
||||
"the full output of every call is saved under "
|
||||
"/tmp/zshell-llm/webfetch/, where it can be paged through with "
|
||||
"the readfile tool. This tool is read-only.");
|
||||
}
|
||||
|
||||
QJsonObject WebFetchTool::parameters() const {
|
||||
@@ -102,9 +110,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
|
||||
}
|
||||
|
||||
void WebFetchTool::execute(
|
||||
const QJsonObject& args, std::function<void(const QJsonObject&)> done) {
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
std::function<void(const QJsonObject&)> done) {
|
||||
auto* job = new Job;
|
||||
job->done = std::move(done);
|
||||
job->toolCallId = toolCallId;
|
||||
m_jobs.append(job);
|
||||
|
||||
auto fail = [this, job](const QString& message) {
|
||||
@@ -233,13 +244,64 @@ void WebFetchTool::execute(
|
||||
if (mime.contains(QLatin1String("text/html")) &&
|
||||
job->format == QLatin1String("text"))
|
||||
content = extractTextFromHtml(content);
|
||||
if (content.size() > MaxOutputChars)
|
||||
content = content.left(MaxOutputChars) +
|
||||
QStringLiteral("\n[... truncated ...]");
|
||||
completeJob(job, makeOutput(content));
|
||||
|
||||
const QString savedPath = saveToFile(*job, content, mime);
|
||||
|
||||
QString output = content;
|
||||
const int budget = inlineBudgetChars();
|
||||
if (content.size() > budget) {
|
||||
output = content.left(budget);
|
||||
const QString note = QStringLiteral(
|
||||
"\n\n[... truncated: showing %1 of %2 characters")
|
||||
.arg(budget)
|
||||
.arg(content.size());
|
||||
if (!savedPath.isEmpty())
|
||||
output += note +
|
||||
QStringLiteral(
|
||||
". The full content is saved to %1; use "
|
||||
"the readfile tool to read the rest.")
|
||||
.arg(savedPath);
|
||||
else
|
||||
output += note + QStringLiteral(
|
||||
". The remaining content is not "
|
||||
"available.");
|
||||
}
|
||||
completeJob(job, makeOutput(output));
|
||||
});
|
||||
}
|
||||
|
||||
QString WebFetchTool::saveToFile(
|
||||
const Job& job, const QString& content, const QString& mime) const {
|
||||
QDir dir(StoragePath);
|
||||
if (!dir.exists() && !dir.mkpath(QStringLiteral(".")))
|
||||
return QString();
|
||||
|
||||
QString fileName;
|
||||
fileName.reserve(job.toolCallId.size());
|
||||
for (const QChar& c : job.toolCallId)
|
||||
fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') ||
|
||||
c == QLatin1Char('_'))
|
||||
? c
|
||||
: QLatin1Char('_');
|
||||
if (fileName.isEmpty()) fileName = QStringLiteral("fetch");
|
||||
|
||||
QString extension = QStringLiteral("txt");
|
||||
if (job.format == QLatin1String("html"))
|
||||
extension = QStringLiteral("html");
|
||||
else if (mime.contains(QLatin1String("json")))
|
||||
extension = QStringLiteral("json");
|
||||
else if (mime.contains(QLatin1String("xml")))
|
||||
extension = QStringLiteral("xml");
|
||||
|
||||
const QString path =
|
||||
dir.filePath(fileName + QLatin1Char('.') + extension);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
return QString();
|
||||
file.write(content.toUtf8());
|
||||
return path;
|
||||
}
|
||||
|
||||
void WebFetchTool::cancel() {
|
||||
for (auto* job : m_jobs) {
|
||||
job->timer->stop();
|
||||
|
||||
@@ -20,7 +20,9 @@ class WebFetchTool : public LlmTool {
|
||||
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
|
||||
static constexpr int DefaultTimeoutSeconds = 30;
|
||||
static constexpr int MaxTimeoutSeconds = 120;
|
||||
static constexpr int MaxOutputChars = 64 * 1024;
|
||||
// Where the full (untruncated) output of each call is stored so the
|
||||
// model can page through it with the readfile tool.
|
||||
static const QString StoragePath;
|
||||
|
||||
explicit WebFetchTool(QObject* parent = nullptr);
|
||||
~WebFetchTool() override;
|
||||
@@ -29,6 +31,7 @@ class WebFetchTool : public LlmTool {
|
||||
QString description() const override;
|
||||
QJsonObject parameters() const override;
|
||||
void execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
std::function<void(const QJsonObject& result)> done) override;
|
||||
void cancel() override;
|
||||
@@ -43,10 +46,15 @@ class WebFetchTool : public LlmTool {
|
||||
QByteArray body;
|
||||
bool tooLarge = false;
|
||||
QString format;
|
||||
QString toolCallId;
|
||||
std::function<void(const QJsonObject& result)> done;
|
||||
};
|
||||
|
||||
void completeJob(Job* job, QJsonObject result);
|
||||
// Writes the full output to StoragePath; returns the file path, or an
|
||||
// empty string when saving failed.
|
||||
QString saveToFile(const Job& job, const QString& content,
|
||||
const QString& mime) const;
|
||||
|
||||
QNetworkAccessManager m_manager;
|
||||
QList<Job*> m_jobs;
|
||||
|
||||
Reference in New Issue
Block a user