fix: truncate context logic
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m6s
C++ / clang-tidy (pull_request) Failing after 2m5s
JS/TS / fmt (pull_request) Failing after 3m8s
Python / static (pull_request) Successful in 24s
Python / verify (pull_request) Successful in 1m21s
JS/TS / lint (pull_request) Successful in 5m10s
Rust / fmt (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 56s
Rust / clippy (pull_request) Successful in 46s
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m6s
C++ / clang-tidy (pull_request) Failing after 2m5s
JS/TS / fmt (pull_request) Failing after 3m8s
Python / static (pull_request) Successful in 24s
Python / verify (pull_request) Successful in 1m21s
JS/TS / lint (pull_request) Successful in 5m10s
Rust / fmt (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 56s
Rust / clippy (pull_request) Successful in 46s
This commit is contained in:
@@ -154,11 +154,12 @@ Item {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
||||||
function reparentWrapper(): void {
|
function reparentWrapper(): void {
|
||||||
const newParent = open ? rootParent : toolsBg;
|
const newParent = open ? toolsBg.rootParent : toolsBg;
|
||||||
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
|
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
|
||||||
toolsWrapper.parent = newParent;
|
toolsWrapper.parent = newParent;
|
||||||
toolsWrapper.x = pos.x;
|
toolsWrapper.x = pos.x;
|
||||||
toolsWrapper.y = pos.y;
|
toolsWrapper.y = pos.y;
|
||||||
|
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
BlobGroup {
|
BlobGroup {
|
||||||
@@ -187,7 +188,7 @@ Item {
|
|||||||
id: toolsWrapper
|
id: toolsWrapper
|
||||||
|
|
||||||
width: toolsBg.width
|
width: toolsBg.width
|
||||||
height: toolList.implicitHeight + toolList.anchors.margins * 2
|
height: toolBox.implicitHeight
|
||||||
|
|
||||||
states: State {
|
states: State {
|
||||||
name: "open"
|
name: "open"
|
||||||
@@ -265,16 +266,24 @@ Item {
|
|||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
id: toolBox
|
id: toolBox
|
||||||
anchors.fill: parent
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: Math.min(implicitHeight, parent.height)
|
||||||
|
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.small
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
onClicked: toolsBg.open = true
|
onClicked: {
|
||||||
|
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||||
|
toolsBg.open = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: toolList
|
id: toolList
|
||||||
anchors.fill: parent
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
anchors.margins: Tokens.padding.medium
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Item {
|
|||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
active: Config.llm.enabled
|
active: Config.llm.enabled
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
property bool tre: true
|
||||||
|
|
||||||
sourceComponent: Item {
|
sourceComponent: Item {
|
||||||
id: chatPage
|
id: chatPage
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ Chat::Chat(QObject* parent)
|
|||||||
});
|
});
|
||||||
|
|
||||||
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
||||||
// A fresh run supersedes the previous error.
|
|
||||||
if (m_client->busy() && !m_lastError.isEmpty()) {
|
if (m_client->busy() && !m_lastError.isEmpty()) {
|
||||||
m_lastError.clear();
|
m_lastError.clear();
|
||||||
Q_EMIT lastErrorChanged();
|
Q_EMIT lastErrorChanged();
|
||||||
@@ -161,6 +160,10 @@ void Chat::refreshModels() {
|
|||||||
m_client->refreshModels();
|
m_client->refreshModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Chat::refreshFromServer() {
|
||||||
|
m_client->refreshFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
void Chat::selectModel(const QString& id) {
|
void Chat::selectModel(const QString& id) {
|
||||||
if (id.isEmpty()) return;
|
if (id.isEmpty()) return;
|
||||||
m_client->setModel(id);
|
m_client->setModel(id);
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ namespace ZShell::llm {
|
|||||||
|
|
||||||
class LlmClient;
|
class LlmClient;
|
||||||
|
|
||||||
// QML-facing facade. Persistence lives in ChatStore, network streaming in
|
|
||||||
// LlmClient; this class only wires them together and exposes the
|
|
||||||
// application-wide state.
|
|
||||||
class Chat : public QObject {
|
class Chat : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
QML_ELEMENT
|
QML_ELEMENT
|
||||||
@@ -55,6 +52,7 @@ class Chat : public QObject {
|
|||||||
Q_INVOKABLE void stop();
|
Q_INVOKABLE void stop();
|
||||||
Q_INVOKABLE void dismissError();
|
Q_INVOKABLE void dismissError();
|
||||||
Q_INVOKABLE void refreshModels();
|
Q_INVOKABLE void refreshModels();
|
||||||
|
Q_INVOKABLE void refreshFromServer();
|
||||||
Q_INVOKABLE void selectModel(const QString& id);
|
Q_INVOKABLE void selectModel(const QString& id);
|
||||||
|
|
||||||
static Chat* create(QQmlEngine*, QJSEngine*);
|
static Chat* create(QQmlEngine*, QJSEngine*);
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ class CodeHighlighter : public QObject {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
struct State {
|
struct State {
|
||||||
bool bad = false; // permanent failure, do not retry
|
bool bad = false;
|
||||||
void* lib = nullptr;
|
void* lib = nullptr;
|
||||||
const void* lang = nullptr; // const TSLanguage*
|
const void* lang = nullptr;
|
||||||
void* query = nullptr; // TSQuery*
|
void* query = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
||||||
@@ -59,15 +59,14 @@ class CodeHighlighter : public QObject {
|
|||||||
const QVariantList& spans) const;
|
const QVariantList& spans) const;
|
||||||
|
|
||||||
struct SpanCacheEntry {
|
struct SpanCacheEntry {
|
||||||
QString code; // re-compared on lookup; a hash collision can
|
QString code;
|
||||||
// never deliver the wrong spans
|
|
||||||
QVariantList spans;
|
QVariantList spans;
|
||||||
};
|
};
|
||||||
|
|
||||||
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
||||||
mutable QMutex m_stateMutex;
|
mutable QMutex m_stateMutex;
|
||||||
mutable QHash<QString, SpanCacheEntry> m_spanCache;
|
mutable QHash<QString, SpanCacheEntry> m_spanCache;
|
||||||
mutable QStringList m_spanCacheOrder; // LRU order, oldest first
|
mutable QStringList m_spanCacheOrder;
|
||||||
mutable int m_spanCacheBytes = 0;
|
mutable int m_spanCacheBytes = 0;
|
||||||
mutable QMutex m_cacheMutex;
|
mutable QMutex m_cacheMutex;
|
||||||
static CodeHighlighter* s_instance;
|
static CodeHighlighter* s_instance;
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ QJsonObject FileReadTool::parameters() const {
|
|||||||
QJsonObject limit;
|
QJsonObject limit;
|
||||||
limit[QStringLiteral("type")] = QStringLiteral("integer");
|
limit[QStringLiteral("type")] = QStringLiteral("integer");
|
||||||
limit[QStringLiteral("minimum")] = 1;
|
limit[QStringLiteral("minimum")] = 1;
|
||||||
limit[QStringLiteral("description")] =
|
limit[QStringLiteral("description")] = QStringLiteral(
|
||||||
QStringLiteral("Maximum number of characters to return. Defaults "
|
"Maximum number of characters to return. Defaults "
|
||||||
"to a value that fits the model's context window.");
|
"to a value that fits the model's context window.");
|
||||||
|
|
||||||
QJsonObject properties;
|
QJsonObject properties;
|
||||||
properties[QStringLiteral("path")] = path;
|
properties[QStringLiteral("path")] = path;
|
||||||
@@ -74,6 +74,7 @@ QJsonObject FileReadTool::parameters() const {
|
|||||||
void FileReadTool::execute(
|
void FileReadTool::execute(
|
||||||
const QString& toolCallId,
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject&)> done) {
|
std::function<void(const QJsonObject&)> done) {
|
||||||
Q_UNUSED(toolCallId);
|
Q_UNUSED(toolCallId);
|
||||||
const QString path = args[QStringLiteral("path")].toString().trimmed();
|
const QString path = args[QStringLiteral("path")].toString().trimmed();
|
||||||
@@ -82,7 +83,8 @@ void FileReadTool::execute(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
|
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
|
||||||
const int budget = inlineBudgetChars();
|
int budget = inlineBudgetChars();
|
||||||
|
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars);
|
||||||
const int limit =
|
const int limit =
|
||||||
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
|
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
|
||||||
|
|
||||||
@@ -90,8 +92,9 @@ void FileReadTool::execute(
|
|||||||
if (!file.open(QIODevice::ReadOnly)) {
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
const QFileInfo info(path);
|
const QFileInfo info(path);
|
||||||
if (info.exists() && info.isDir()) {
|
if (info.exists() && info.isDir()) {
|
||||||
done(makeError(QStringLiteral("Path is a directory, not a "
|
done(makeError(QStringLiteral(
|
||||||
"file: %1")
|
"Path is a directory, not a "
|
||||||
|
"file: %1")
|
||||||
.arg(path)));
|
.arg(path)));
|
||||||
} else {
|
} else {
|
||||||
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
|
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
|
||||||
@@ -106,8 +109,9 @@ void FileReadTool::execute(
|
|||||||
}
|
}
|
||||||
if (offset >= fileSize) {
|
if (offset >= fileSize) {
|
||||||
file.close();
|
file.close();
|
||||||
done(makeError(QStringLiteral("Offset %1 is beyond the end of the "
|
done(makeError(QStringLiteral(
|
||||||
"file (%2 bytes)")
|
"Offset %1 is beyond the end of the "
|
||||||
|
"file (%2 bytes)")
|
||||||
.arg(offset)
|
.arg(offset)
|
||||||
.arg(fileSize)));
|
.arg(fileSize)));
|
||||||
return;
|
return;
|
||||||
@@ -117,15 +121,13 @@ void FileReadTool::execute(
|
|||||||
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
|
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
|
||||||
return;
|
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 qint64 wanted = qint64(limit) * 4 + 16;
|
||||||
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
|
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
|
||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (chunk.contains('\0')) {
|
if (chunk.contains('\0')) {
|
||||||
done(makeError(QStringLiteral("Binary files are not supported: %1")
|
done(makeError(
|
||||||
.arg(path)));
|
QStringLiteral("Binary files are not supported: %1").arg(path)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const QString content = QString::fromUtf8(chunk);
|
const QString content = QString::fromUtf8(chunk);
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
|
|
||||||
namespace ZShell::llm {
|
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 {
|
class FileReadTool : public LlmTool {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -20,6 +16,7 @@ class FileReadTool : public LlmTool {
|
|||||||
void execute(
|
void execute(
|
||||||
const QString& toolCallId,
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject& result)> done) override;
|
std::function<void(const QJsonObject& result)> done) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -220,8 +220,6 @@ LlmSegment* ChatGeneration::beginToolCall(
|
|||||||
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
|
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
|
||||||
segment->setName(name);
|
segment->setName(name);
|
||||||
segment->setToolCallId(toolCallId);
|
segment->setToolCallId(toolCallId);
|
||||||
// Awaiting user approval until LlmClient::approveTools() promotes it
|
|
||||||
// to Running; denyTools()/endStream() finalize it as an error.
|
|
||||||
segment->setStatus(LlmSegment::Status::Pending);
|
segment->setStatus(LlmSegment::Status::Pending);
|
||||||
segment->begin();
|
segment->begin();
|
||||||
addSegment(segment);
|
addSegment(segment);
|
||||||
|
|||||||
@@ -12,15 +12,16 @@
|
|||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
namespace ZShell::llm {
|
namespace ZShell::llm {
|
||||||
|
|
||||||
namespace {
|
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;
|
constexpr int kMinContextTokens = 512;
|
||||||
|
|
||||||
qsizetype jsonValueChars(const QJsonValue& value) {
|
qsizetype jsonValueChars(const QJsonValue& value) {
|
||||||
@@ -49,16 +50,11 @@ int estimateMessageTokens(const QJsonObject& message) {
|
|||||||
return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4;
|
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 {
|
struct ContextUnit {
|
||||||
QList<QJsonObject> messages;
|
QList<QJsonObject> messages;
|
||||||
int tokens = 0;
|
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) {
|
void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
|
||||||
static const QStringList kTextKeys = {
|
static const QStringList kTextKeys = {
|
||||||
QStringLiteral("content"), QStringLiteral("reasoning_content")};
|
QStringLiteral("content"), QStringLiteral("reasoning_content")};
|
||||||
@@ -90,6 +86,41 @@ void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int estimateArrayTokens(const QJsonArray& messages) {
|
||||||
|
int total = 0;
|
||||||
|
for (const QJsonValue& value : messages)
|
||||||
|
total += estimateMessageTokens(value.toObject());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int kToolResultStubChars = 512;
|
||||||
|
|
||||||
|
constexpr double kPromptEstimateDiscount = 10.0 / 12.0;
|
||||||
|
|
||||||
|
constexpr int kMinToolResultTokens = 128;
|
||||||
|
|
||||||
|
int completionReserve(int contextSize) {
|
||||||
|
return qBound(2048, contextSize / 8, 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
int promptBudgetTokens(int contextSize) {
|
||||||
|
return int(
|
||||||
|
(contextSize - completionReserve(contextSize)) *
|
||||||
|
kPromptEstimateDiscount);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
} // namespace
|
||||||
|
|
||||||
QString LlmClient::completionsPath(
|
QString LlmClient::completionsPath(
|
||||||
@@ -140,7 +171,12 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
|
|||||||
&ToolRegistry::enabledChanged,
|
&ToolRegistry::enabledChanged,
|
||||||
this,
|
this,
|
||||||
&LlmClient::toolsEnabledChanged);
|
&LlmClient::toolsEnabledChanged);
|
||||||
probeContextSize();
|
|
||||||
|
m_refreshTimer.setParent(this);
|
||||||
|
m_refreshTimer.setInterval(kRefreshIntervalMs);
|
||||||
|
connect(
|
||||||
|
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
|
||||||
|
m_refreshTimer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
LlmClient::~LlmClient() {
|
LlmClient::~LlmClient() {
|
||||||
@@ -153,8 +189,8 @@ void LlmClient::setEndpoint(const QString& value) {
|
|||||||
if (m_endpoint == value) return;
|
if (m_endpoint == value) return;
|
||||||
m_endpoint = value;
|
m_endpoint = value;
|
||||||
Q_EMIT endpointChanged();
|
Q_EMIT endpointChanged();
|
||||||
probeContextSize();
|
m_refreshTimer.start();
|
||||||
if (m_model.isEmpty()) refreshModels();
|
refreshFromServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::setModel(const QString& value) {
|
void LlmClient::setModel(const QString& value) {
|
||||||
@@ -170,6 +206,8 @@ void LlmClient::setTemperature(double value) {
|
|||||||
|
|
||||||
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
||||||
if (m_busy || !session || !target) return;
|
if (m_busy || !session || !target) return;
|
||||||
|
refreshModels();
|
||||||
|
m_contextRetryUsed = false;
|
||||||
m_active = session;
|
m_active = session;
|
||||||
m_streaming = target;
|
m_streaming = target;
|
||||||
target->setStreaming(true);
|
target->setStreaming(true);
|
||||||
@@ -202,11 +240,15 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
|||||||
m_reasoningMark = 0;
|
m_reasoningMark = 0;
|
||||||
setApprovalPending(false);
|
setApprovalPending(false);
|
||||||
|
|
||||||
sendRound();
|
probeContextSize([this, session, target]() {
|
||||||
|
if (m_active != session || m_streaming != target) return;
|
||||||
|
sendRound();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::sendRound() {
|
void LlmClient::sendRound() {
|
||||||
if (!m_active || !m_streaming) return;
|
if (!m_active || !m_streaming) return;
|
||||||
|
probeContextSize();
|
||||||
m_finishReason.clear();
|
m_finishReason.clear();
|
||||||
m_callBuilders.clear();
|
m_callBuilders.clear();
|
||||||
m_callResults.clear();
|
m_callResults.clear();
|
||||||
@@ -234,8 +276,6 @@ void LlmClient::sendRound() {
|
|||||||
return;
|
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;
|
int transcriptTokens = 0;
|
||||||
for (const QJsonValue& value : m_transcript)
|
for (const QJsonValue& value : m_transcript)
|
||||||
transcriptTokens += estimateMessageTokens(value.toObject());
|
transcriptTokens += estimateMessageTokens(value.toObject());
|
||||||
@@ -244,6 +284,30 @@ void LlmClient::sendRound() {
|
|||||||
for (const QJsonValue& value : m_transcript)
|
for (const QJsonValue& value : m_transcript)
|
||||||
messages.append(value);
|
messages.append(value);
|
||||||
|
|
||||||
|
if (m_contextSize > 0) {
|
||||||
|
const int promptLimit = promptBudgetTokens(m_contextSize);
|
||||||
|
while (estimateArrayTokens(messages) > promptLimit) {
|
||||||
|
bool stubbed = false;
|
||||||
|
for (qsizetype i = 0; i < messages.size(); ++i) {
|
||||||
|
QJsonObject message = messages.at(i).toObject();
|
||||||
|
if (message.value("role").toString() != QLatin1String("tool"))
|
||||||
|
continue;
|
||||||
|
const QString content = message.value("content").toString();
|
||||||
|
if (content.size() <= kToolResultStubChars) continue;
|
||||||
|
QString stub = content.left(kToolResultStubChars);
|
||||||
|
stub += QStringLiteral(
|
||||||
|
"\n[truncated to fit the context window; was "
|
||||||
|
"%1 characters]")
|
||||||
|
.arg(content.size());
|
||||||
|
message["content"] = stub;
|
||||||
|
messages[i] = message;
|
||||||
|
stubbed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!stubbed) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject body;
|
QJsonObject body;
|
||||||
QJsonObject streamOptions;
|
QJsonObject streamOptions;
|
||||||
streamOptions[QStringLiteral("include_usage")] = true;
|
streamOptions[QStringLiteral("include_usage")] = true;
|
||||||
@@ -283,8 +347,21 @@ void LlmClient::sendRound() {
|
|||||||
roundFinished();
|
roundFinished();
|
||||||
else if (error == QNetworkReply::OperationCanceledError)
|
else if (error == QNetworkReply::OperationCanceledError)
|
||||||
finishTurn();
|
finishTurn();
|
||||||
else
|
else {
|
||||||
fail(serverErrorMessage(responseBody, errorString));
|
const QString message =
|
||||||
|
serverErrorMessage(responseBody, errorString);
|
||||||
|
const int reported = contextSizeFromErrorMessage(message);
|
||||||
|
if (reported > 0 && reported != m_contextSize &&
|
||||||
|
!m_contextRetryUsed) {
|
||||||
|
qWarning() << "LlmClient: prompt overflow, server reports"
|
||||||
|
<< reported << "tokens; retrying round";
|
||||||
|
setContextSize(reported);
|
||||||
|
m_contextRetryUsed = true;
|
||||||
|
sendRound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,21 +426,17 @@ QJsonArray LlmClient::buildContextMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (m_contextSize > 0) {
|
if (m_contextSize > 0) {
|
||||||
// Keep headroom for the model's own reply so the prompt cannot
|
int budget = m_contextSize - completionReserve(m_contextSize) -
|
||||||
// fill the whole window.
|
extraReserveTokens;
|
||||||
const int completionReserve = qBound(1024, m_contextSize / 8, 8192);
|
|
||||||
int budget = m_contextSize - completionReserve - extraReserveTokens;
|
|
||||||
budget = qMax(budget, kMinContextTokens);
|
budget = qMax(budget, kMinContextTokens);
|
||||||
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
for (const ContextUnit& unit : units)
|
for (const ContextUnit& unit : units)
|
||||||
total += unit.tokens;
|
total += unit.tokens;
|
||||||
// Drop oldest units until the prompt fits the budget.
|
|
||||||
while (units.size() > 1 && total > budget) {
|
while (units.size() > 1 && total > budget) {
|
||||||
total -= units.first().tokens;
|
total -= units.first().tokens;
|
||||||
units.removeFirst();
|
units.removeFirst();
|
||||||
}
|
}
|
||||||
// A conversation cannot start with an assistant turn.
|
|
||||||
while (
|
while (
|
||||||
units.size() > 1 &&
|
units.size() > 1 &&
|
||||||
units.first().messages.first()[QStringLiteral("role")].toString() ==
|
units.first().messages.first()[QStringLiteral("role")].toString() ==
|
||||||
@@ -371,7 +444,6 @@ QJsonArray LlmClient::buildContextMessages(
|
|||||||
total -= units.first().tokens;
|
total -= units.first().tokens;
|
||||||
units.removeFirst();
|
units.removeFirst();
|
||||||
}
|
}
|
||||||
// A single oversized unit still gets sent, shrunk to fit.
|
|
||||||
if (units.size() == 1 && units.first().tokens > budget)
|
if (units.size() == 1 && units.first().tokens > budget)
|
||||||
shrinkUnitToBudget(units.first().messages, budget);
|
shrinkUnitToBudget(units.first().messages, budget);
|
||||||
}
|
}
|
||||||
@@ -417,7 +489,21 @@ void LlmClient::roundFinished() {
|
|||||||
bool hasCalls = false;
|
bool hasCalls = false;
|
||||||
for (const auto& builder : m_callBuilders)
|
for (const auto& builder : m_callBuilders)
|
||||||
if (builder.seen) hasCalls = true;
|
if (builder.seen) hasCalls = true;
|
||||||
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
|
if (!hasCalls) {
|
||||||
|
finishTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_finishReason != QLatin1String("tool_calls")) {
|
||||||
|
qWarning() << "LlmClient: stream ended" << m_finishReason
|
||||||
|
<< "with incomplete tool calls, ending turn";
|
||||||
|
for (const auto& builder : m_callBuilders) {
|
||||||
|
if (!builder.seen || !builder.segment) continue;
|
||||||
|
builder.segment->finishTool(
|
||||||
|
QStringLiteral(
|
||||||
|
"Stream truncated before the tool call "
|
||||||
|
"completed"),
|
||||||
|
false);
|
||||||
|
}
|
||||||
finishTurn();
|
finishTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -458,8 +544,6 @@ void LlmClient::roundFinished() {
|
|||||||
m_transcript.append(assistant);
|
m_transcript.append(assistant);
|
||||||
m_round++;
|
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);
|
setApprovalPending(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +552,18 @@ void LlmClient::executeAllCalls() {
|
|||||||
m_pendingCalls = 0;
|
m_pendingCalls = 0;
|
||||||
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
|
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
|
||||||
|
|
||||||
|
int batchSize = 0;
|
||||||
|
for (const auto& call : m_callBuilders)
|
||||||
|
if (call.seen) ++batchSize;
|
||||||
|
int perCallChars = 0;
|
||||||
|
if (batchSize > 0 && m_contextSize > 0) {
|
||||||
|
const int available = promptBudgetTokens(m_contextSize) -
|
||||||
|
estimateArrayTokens(m_transcript);
|
||||||
|
const int perCallTokens =
|
||||||
|
qMax(available / batchSize, kMinToolResultTokens);
|
||||||
|
perCallChars = perCallTokens * LlmTool::CharsPerToken;
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < m_callBuilders.size(); ++i) {
|
for (int i = 0; i < m_callBuilders.size(); ++i) {
|
||||||
const auto& call = m_callBuilders.at(i);
|
const auto& call = m_callBuilders.at(i);
|
||||||
if (!call.seen) continue;
|
if (!call.seen) continue;
|
||||||
@@ -498,18 +594,22 @@ void LlmClient::executeAllCalls() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
++m_pendingCalls;
|
++m_pendingCalls;
|
||||||
tool->execute(call.id, args, [this, i, call](const QJsonObject& result) {
|
tool->execute(
|
||||||
if (!m_streaming) return;
|
call.id,
|
||||||
const bool success = result.contains(QStringLiteral("output"));
|
args,
|
||||||
const QString content =
|
perCallChars,
|
||||||
success ? result[QStringLiteral("output")].toString()
|
[this, i, call](const QJsonObject& result) {
|
||||||
: QStringLiteral("Error: ") +
|
if (!m_streaming) return;
|
||||||
result[QStringLiteral("error")].toString();
|
const bool success = result.contains(QStringLiteral("output"));
|
||||||
m_callResults[i] = {content, success};
|
const QString content =
|
||||||
if (LlmSegment* segment = call.segment)
|
success ? result[QStringLiteral("output")].toString()
|
||||||
segment->finishTool(content, success);
|
: QStringLiteral("Error: ") +
|
||||||
if (--m_pendingCalls == 0) flushCallResults();
|
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();
|
if (m_pendingCalls == 0) flushCallResults();
|
||||||
}
|
}
|
||||||
@@ -533,8 +633,6 @@ void LlmClient::flushCallResults() {
|
|||||||
void LlmClient::setApprovalPending(bool value) {
|
void LlmClient::setApprovalPending(bool value) {
|
||||||
if (m_approvalPending == value) return;
|
if (m_approvalPending == value) return;
|
||||||
m_approvalPending = value;
|
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)
|
if (ChatGeneration* streaming = m_streaming)
|
||||||
streaming->setApprovalPending(value);
|
streaming->setApprovalPending(value);
|
||||||
}
|
}
|
||||||
@@ -548,8 +646,6 @@ void LlmClient::approveTools() {
|
|||||||
if (segment->type() != LlmSegment::Type::ToolCall ||
|
if (segment->type() != LlmSegment::Type::ToolCall ||
|
||||||
segment->status() != LlmSegment::Status::Pending)
|
segment->status() != LlmSegment::Status::Pending)
|
||||||
continue;
|
continue;
|
||||||
// Restart the segment timer so elapsed time covers execution, not
|
|
||||||
// the wait for approval.
|
|
||||||
segment->setStatus(LlmSegment::Status::Running);
|
segment->setStatus(LlmSegment::Status::Running);
|
||||||
segment->begin();
|
segment->begin();
|
||||||
}
|
}
|
||||||
@@ -592,7 +688,10 @@ void LlmClient::stop() {
|
|||||||
finishTurn();
|
finishTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m_reply) m_reply->abort();
|
if (m_reply)
|
||||||
|
m_reply->abort();
|
||||||
|
else
|
||||||
|
endStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::endStream() {
|
void LlmClient::endStream() {
|
||||||
@@ -604,8 +703,6 @@ void LlmClient::endStream() {
|
|||||||
generation->setStreaming(false);
|
generation->setStreaming(false);
|
||||||
m_approvalPending = false;
|
m_approvalPending = false;
|
||||||
generation->setApprovalPending(false);
|
generation->setApprovalPending(false);
|
||||||
// Finalize tool calls that never received an approval (turn cancelled,
|
|
||||||
// round limit reached, ...).
|
|
||||||
for (auto* segment : generation->segments())
|
for (auto* segment : generation->segments())
|
||||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||||
segment->status() == LlmSegment::Status::Pending)
|
segment->status() == LlmSegment::Status::Pending)
|
||||||
@@ -633,6 +730,7 @@ void LlmClient::finishTurn() {
|
|||||||
m_pendingClear.clear();
|
m_pendingClear.clear();
|
||||||
}
|
}
|
||||||
if (session) session->persist();
|
if (session) session->persist();
|
||||||
|
refreshFromServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::clearOnFinish(ChatSession* session) {
|
void LlmClient::clearOnFinish(ChatSession* session) {
|
||||||
@@ -707,6 +805,12 @@ void LlmClient::handleLine(const QByteArray& line) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LlmClient::refreshFromServer() {
|
||||||
|
if (m_endpoint.trimmed().isEmpty()) return;
|
||||||
|
probeContextSize();
|
||||||
|
refreshModels();
|
||||||
|
}
|
||||||
|
|
||||||
void LlmClient::refreshModels() {
|
void LlmClient::refreshModels() {
|
||||||
const QUrl url =
|
const QUrl url =
|
||||||
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
|
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
|
||||||
@@ -742,6 +846,9 @@ void LlmClient::refreshModels() {
|
|||||||
if (m_model.isEmpty()) {
|
if (m_model.isEmpty()) {
|
||||||
m_model = models.first();
|
m_model = models.first();
|
||||||
Q_EMIT modelChanged();
|
Q_EMIT modelChanged();
|
||||||
|
} else if (!models.contains(m_model)) {
|
||||||
|
m_model = models.first();
|
||||||
|
Q_EMIT modelChanged();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -753,15 +860,22 @@ void LlmClient::setContextSize(int size) {
|
|||||||
Q_EMIT contextSizeChanged();
|
Q_EMIT contextSizeChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::probeContextSize() {
|
void LlmClient::probeContextSize(std::function<void()> done) {
|
||||||
QString base = m_endpoint.trimmed();
|
QString base = m_endpoint.trimmed();
|
||||||
while (base.endsWith('/'))
|
while (base.endsWith('/'))
|
||||||
base.chop(1);
|
base.chop(1);
|
||||||
const QUrl url = QUrl::fromUserInput(base + "/props");
|
const QUrl url = QUrl::fromUserInput(base + "/props");
|
||||||
if (!url.isValid() || url.host().isEmpty()) return;
|
if (!url.isValid() || url.host().isEmpty()) {
|
||||||
|
if (done) done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
auto* reply = m_manager.get(QNetworkRequest(url));
|
auto* reply = m_manager.get(QNetworkRequest(url));
|
||||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
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 QNetworkReply::NetworkError error = reply->error();
|
||||||
const QByteArray data = reply->readAll();
|
const QByteArray data = reply->readAll();
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
@@ -786,7 +900,15 @@ void LlmClient::probeContextSize() {
|
|||||||
.toInt(0);
|
.toInt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setContextSize(size > 0 ? size : 4096);
|
if (size > 0)
|
||||||
|
setContextSize(size);
|
||||||
|
else if (m_contextSize <= 0)
|
||||||
|
setContextSize(4096);
|
||||||
|
if (done) done();
|
||||||
|
};
|
||||||
|
connect(reply, &QNetworkReply::finished, this, finish);
|
||||||
|
QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() {
|
||||||
|
if (!*settled) reply->abort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ class LlmClient : public QObject {
|
|||||||
void setModel(const QString& value);
|
void setModel(const QString& value);
|
||||||
void setTemperature(double value);
|
void setTemperature(double value);
|
||||||
void setContextSize(int size);
|
void setContextSize(int size);
|
||||||
void probeContextSize();
|
void probeContextSize(std::function<void()> done = {});
|
||||||
|
Q_INVOKABLE void refreshFromServer();
|
||||||
|
|
||||||
[[nodiscard]] bool busy() const { return m_busy; }
|
[[nodiscard]] bool busy() const { return m_busy; }
|
||||||
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
|
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
|
||||||
@@ -56,10 +58,6 @@ class LlmClient : public QObject {
|
|||||||
void clearOnFinish(ChatSession* session);
|
void clearOnFinish(ChatSession* session);
|
||||||
void sessionRemoved(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 approveTools();
|
||||||
void denyTools();
|
void denyTools();
|
||||||
|
|
||||||
@@ -118,9 +116,7 @@ class LlmClient : public QObject {
|
|||||||
ToolRegistry* m_tools = nullptr;
|
ToolRegistry* m_tools = nullptr;
|
||||||
QNetworkReply* m_reply = nullptr;
|
QNetworkReply* m_reply = nullptr;
|
||||||
QByteArray m_buffer;
|
QByteArray m_buffer;
|
||||||
// QPointer: these objects are owned by the ChatStore tree, which Qt
|
QTimer m_refreshTimer;
|
||||||
// destroys *before* this client on shutdown (children die in creation
|
|
||||||
// order). The pointers must self-null instead of dangling.
|
|
||||||
QPointer<ChatSession> m_active;
|
QPointer<ChatSession> m_active;
|
||||||
QPointer<ChatGeneration> m_streaming;
|
QPointer<ChatGeneration> m_streaming;
|
||||||
QPointer<ChatSession> m_pendingClear;
|
QPointer<ChatSession> m_pendingClear;
|
||||||
@@ -146,6 +142,7 @@ class LlmClient : public QObject {
|
|||||||
bool m_roundDone = false;
|
bool m_roundDone = false;
|
||||||
bool m_toolPhase = false;
|
bool m_toolPhase = false;
|
||||||
bool m_approvalPending = false;
|
bool m_approvalPending = false;
|
||||||
|
bool m_contextRetryUsed = false;
|
||||||
int m_pendingCalls = 0;
|
int m_pendingCalls = 0;
|
||||||
static constexpr int kMaxToolRounds = 12;
|
static constexpr int kMaxToolRounds = 12;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ class LlmSegment : public QObject {
|
|||||||
enum class Type : int { Reasoning = 0, ToolCall, Content };
|
enum class Type : int { Reasoning = 0, ToolCall, Content };
|
||||||
Q_ENUM(Type)
|
Q_ENUM(Type)
|
||||||
|
|
||||||
// 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 };
|
enum class Status : int { None = 0, Running, Success, Error, Pending };
|
||||||
Q_ENUM(Status)
|
Q_ENUM(Status)
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ void LlmTool::setContextSize(int value) {
|
|||||||
|
|
||||||
int LlmTool::inlineBudgetChars() const {
|
int LlmTool::inlineBudgetChars() const {
|
||||||
if (m_contextSize <= 0) return DefaultInlineChars;
|
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 =
|
const int tokens =
|
||||||
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
|
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
|
||||||
return tokens * CharsPerToken;
|
return tokens * CharsPerToken;
|
||||||
|
|||||||
@@ -21,29 +21,21 @@ class LlmTool : public QObject {
|
|||||||
[[nodiscard]] virtual QString description() const = 0;
|
[[nodiscard]] virtual QString description() const = 0;
|
||||||
[[nodiscard]] virtual QJsonObject parameters() 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(
|
virtual void execute(
|
||||||
const QString& toolCallId,
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject& result)> done) = 0;
|
std::function<void(const QJsonObject& result)> done) = 0;
|
||||||
virtual void cancel();
|
virtual void cancel();
|
||||||
|
|
||||||
[[nodiscard]] QJsonObject specification() const;
|
[[nodiscard]] QJsonObject specification() const;
|
||||||
|
|
||||||
// The endpoint's context size in tokens (0 = unknown). Set by LlmClient.
|
|
||||||
[[nodiscard]] int contextSize() const { return m_contextSize; }
|
[[nodiscard]] int contextSize() const { return m_contextSize; }
|
||||||
void setContextSize(int value);
|
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;
|
[[nodiscard]] int inlineBudgetChars() const;
|
||||||
|
|
||||||
// ~4 characters per token; a deliberately coarse estimate used for
|
|
||||||
// budgeting (never for exact accounting).
|
|
||||||
static constexpr int CharsPerToken = 4;
|
static constexpr int CharsPerToken = 4;
|
||||||
// Inline budget when the context size is unknown.
|
|
||||||
static constexpr int DefaultInlineChars = 64 * 1024;
|
static constexpr int DefaultInlineChars = 64 * 1024;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -66,7 +58,6 @@ class ToolRegistry : public QObject {
|
|||||||
[[nodiscard]] QJsonArray specifications() const;
|
[[nodiscard]] QJsonArray specifications() const;
|
||||||
void cancelAll();
|
void cancelAll();
|
||||||
|
|
||||||
// Propagated to every registered tool, including later ones.
|
|
||||||
void setContextSize(int value);
|
void setContextSize(int value);
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
|
|||||||
@@ -112,10 +112,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
|
|||||||
void WebFetchTool::execute(
|
void WebFetchTool::execute(
|
||||||
const QString& toolCallId,
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject&)> done) {
|
std::function<void(const QJsonObject&)> done) {
|
||||||
auto* job = new Job;
|
auto* job = new Job;
|
||||||
job->done = std::move(done);
|
job->done = std::move(done);
|
||||||
job->toolCallId = toolCallId;
|
job->toolCallId = toolCallId;
|
||||||
|
job->budgetChars = outputBudgetChars;
|
||||||
m_jobs.append(job);
|
m_jobs.append(job);
|
||||||
|
|
||||||
auto fail = [this, job](const QString& message) {
|
auto fail = [this, job](const QString& message) {
|
||||||
@@ -248,23 +250,24 @@ void WebFetchTool::execute(
|
|||||||
const QString savedPath = saveToFile(*job, content, mime);
|
const QString savedPath = saveToFile(*job, content, mime);
|
||||||
|
|
||||||
QString output = content;
|
QString output = content;
|
||||||
const int budget = inlineBudgetChars();
|
int budget = inlineBudgetChars();
|
||||||
|
if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars);
|
||||||
if (content.size() > budget) {
|
if (content.size() > budget) {
|
||||||
output = content.left(budget);
|
output = content.left(budget);
|
||||||
const QString note = QStringLiteral(
|
const QString note =
|
||||||
"\n\n[... truncated: showing %1 of %2 characters")
|
QStringLiteral(
|
||||||
.arg(budget)
|
"\n\n[... truncated: showing %1 of %2 characters")
|
||||||
.arg(content.size());
|
.arg(budget)
|
||||||
|
.arg(content.size());
|
||||||
if (!savedPath.isEmpty())
|
if (!savedPath.isEmpty())
|
||||||
output += note +
|
output += note + QStringLiteral(
|
||||||
QStringLiteral(
|
". The full content is saved to %1; use "
|
||||||
". The full content is saved to %1; use "
|
"the readfile tool to read the rest.")
|
||||||
"the readfile tool to read the rest.")
|
.arg(savedPath);
|
||||||
.arg(savedPath);
|
|
||||||
else
|
else
|
||||||
output += note + QStringLiteral(
|
output += note + QStringLiteral(
|
||||||
". The remaining content is not "
|
". The remaining content is not "
|
||||||
"available.");
|
"available.");
|
||||||
}
|
}
|
||||||
completeJob(job, makeOutput(output));
|
completeJob(job, makeOutput(output));
|
||||||
});
|
});
|
||||||
@@ -273,14 +276,13 @@ void WebFetchTool::execute(
|
|||||||
QString WebFetchTool::saveToFile(
|
QString WebFetchTool::saveToFile(
|
||||||
const Job& job, const QString& content, const QString& mime) const {
|
const Job& job, const QString& content, const QString& mime) const {
|
||||||
QDir dir(StoragePath);
|
QDir dir(StoragePath);
|
||||||
if (!dir.exists() && !dir.mkpath(QStringLiteral(".")))
|
if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString();
|
||||||
return QString();
|
|
||||||
|
|
||||||
QString fileName;
|
QString fileName;
|
||||||
fileName.reserve(job.toolCallId.size());
|
fileName.reserve(job.toolCallId.size());
|
||||||
for (const QChar& c : job.toolCallId)
|
for (const QChar& c : job.toolCallId)
|
||||||
fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') ||
|
fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') ||
|
||||||
c == QLatin1Char('_'))
|
c == QLatin1Char('_'))
|
||||||
? c
|
? c
|
||||||
: QLatin1Char('_');
|
: QLatin1Char('_');
|
||||||
if (fileName.isEmpty()) fileName = QStringLiteral("fetch");
|
if (fileName.isEmpty()) fileName = QStringLiteral("fetch");
|
||||||
@@ -293,8 +295,7 @@ QString WebFetchTool::saveToFile(
|
|||||||
else if (mime.contains(QLatin1String("xml")))
|
else if (mime.contains(QLatin1String("xml")))
|
||||||
extension = QStringLiteral("xml");
|
extension = QStringLiteral("xml");
|
||||||
|
|
||||||
const QString path =
|
const QString path = dir.filePath(fileName + QLatin1Char('.') + extension);
|
||||||
dir.filePath(fileName + QLatin1Char('.') + extension);
|
|
||||||
QFile file(path);
|
QFile file(path);
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
return QString();
|
return QString();
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ class WebFetchTool : public LlmTool {
|
|||||||
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
|
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
|
||||||
static constexpr int DefaultTimeoutSeconds = 30;
|
static constexpr int DefaultTimeoutSeconds = 30;
|
||||||
static constexpr int MaxTimeoutSeconds = 120;
|
static constexpr int MaxTimeoutSeconds = 120;
|
||||||
// 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;
|
static const QString StoragePath;
|
||||||
|
|
||||||
explicit WebFetchTool(QObject* parent = nullptr);
|
explicit WebFetchTool(QObject* parent = nullptr);
|
||||||
@@ -33,6 +31,7 @@ class WebFetchTool : public LlmTool {
|
|||||||
void execute(
|
void execute(
|
||||||
const QString& toolCallId,
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject& result)> done) override;
|
std::function<void(const QJsonObject& result)> done) override;
|
||||||
void cancel() override;
|
void cancel() override;
|
||||||
|
|
||||||
@@ -47,14 +46,13 @@ class WebFetchTool : public LlmTool {
|
|||||||
bool tooLarge = false;
|
bool tooLarge = false;
|
||||||
QString format;
|
QString format;
|
||||||
QString toolCallId;
|
QString toolCallId;
|
||||||
|
int budgetChars = 0;
|
||||||
std::function<void(const QJsonObject& result)> done;
|
std::function<void(const QJsonObject& result)> done;
|
||||||
};
|
};
|
||||||
|
|
||||||
void completeJob(Job* job, QJsonObject result);
|
void completeJob(Job* job, QJsonObject result);
|
||||||
// Writes the full output to StoragePath; returns the file path, or an
|
QString saveToFile(
|
||||||
// empty string when saving failed.
|
const Job& job, const QString& content, const QString& mime) const;
|
||||||
QString saveToFile(const Job& job, const QString& content,
|
|
||||||
const QString& mime) const;
|
|
||||||
|
|
||||||
QNetworkAccessManager m_manager;
|
QNetworkAccessManager m_manager;
|
||||||
QList<Job*> m_jobs;
|
QList<Job*> m_jobs;
|
||||||
|
|||||||
Reference in New Issue
Block a user