实现聊天流式输出
This commit is contained in:
@@ -83,6 +83,53 @@ void ConversationManager::sendUserMessage(const QString &message, ResponseCallba
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback)
|
||||||
|
{
|
||||||
|
const QString content = message.trimmed();
|
||||||
|
if (content.isEmpty())
|
||||||
|
{
|
||||||
|
if (callback)
|
||||||
|
{
|
||||||
|
callback({false, {}, QStringLiteral("Message is empty."), 0});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_provider)
|
||||||
|
{
|
||||||
|
if (callback)
|
||||||
|
{
|
||||||
|
callback({false, {}, QStringLiteral("AI provider is not ready."), 0});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusy())
|
||||||
|
{
|
||||||
|
if (callback)
|
||||||
|
{
|
||||||
|
callback({false, {}, QStringLiteral("AI request is already running."), 0});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatMessage userMessage{QStringLiteral("user"), content};
|
||||||
|
m_provider->sendStreamingChatRequest(
|
||||||
|
buildRequest(userMessage),
|
||||||
|
std::move(streamCallback),
|
||||||
|
[this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
|
||||||
|
if (response.success)
|
||||||
|
{
|
||||||
|
appendExchange(userMessage, {QStringLiteral("assistant"), response.content});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback)
|
||||||
|
{
|
||||||
|
callback(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void ConversationManager::cancel()
|
void ConversationManager::cancel()
|
||||||
{
|
{
|
||||||
if (m_provider)
|
if (m_provider)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class ConversationManager
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
using ResponseCallback = LLMProvider::ResponseCallback;
|
using ResponseCallback = LLMProvider::ResponseCallback;
|
||||||
|
using StreamCallback = LLMProvider::StreamCallback;
|
||||||
|
|
||||||
ConversationManager();
|
ConversationManager();
|
||||||
~ConversationManager();
|
~ConversationManager();
|
||||||
@@ -20,6 +21,7 @@ public:
|
|||||||
QVector<ChatMessage> history() const;
|
QVector<ChatMessage> history() const;
|
||||||
bool setProvider(std::unique_ptr<LLMProvider> provider);
|
bool setProvider(std::unique_ptr<LLMProvider> provider);
|
||||||
void sendUserMessage(const QString &message, ResponseCallback callback);
|
void sendUserMessage(const QString &message, ResponseCallback callback);
|
||||||
|
void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
|
||||||
void cancel();
|
void cancel();
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,28 @@
|
|||||||
|
|
||||||
#include "LLMTypes.h"
|
#include "LLMTypes.h"
|
||||||
|
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
class LLMProvider
|
class LLMProvider
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
using ResponseCallback = std::function<void(const ChatResponse &)>;
|
using ResponseCallback = std::function<void(const ChatResponse &)>;
|
||||||
|
using StreamCallback = std::function<void(const QString &)>;
|
||||||
|
|
||||||
virtual ~LLMProvider() = default;
|
virtual ~LLMProvider() = default;
|
||||||
|
|
||||||
virtual bool isBusy() const = 0;
|
virtual bool isBusy() const = 0;
|
||||||
virtual void sendChatRequest(const ChatRequest &request, ResponseCallback callback) = 0;
|
virtual void sendChatRequest(const ChatRequest &request, ResponseCallback callback) = 0;
|
||||||
|
virtual void sendStreamingChatRequest(
|
||||||
|
const ChatRequest &request,
|
||||||
|
StreamCallback streamCallback,
|
||||||
|
ResponseCallback callback)
|
||||||
|
{
|
||||||
|
Q_UNUSED(streamCallback);
|
||||||
|
sendChatRequest(request, std::move(callback));
|
||||||
|
}
|
||||||
virtual void cancel() = 0;
|
virtual void cancel() = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "OpenAICompatibleProvider.h"
|
#include "OpenAICompatibleProvider.h"
|
||||||
|
|
||||||
|
#include "../util/Logger.h"
|
||||||
|
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
@@ -8,21 +10,75 @@
|
|||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
constexpr int MaxDiagnosticBodyLength = 1000;
|
||||||
|
|
||||||
QString trimmedResponseBody(const QByteArray &body)
|
QString trimmedResponseBody(const QByteArray &body)
|
||||||
{
|
{
|
||||||
const QString text = QString::fromUtf8(body).trimmed();
|
const QString text = QString::fromUtf8(body).trimmed();
|
||||||
constexpr int MaxErrorBodyLength = 1000;
|
if (text.size() <= MaxDiagnosticBodyLength)
|
||||||
if (text.size() <= MaxErrorBodyLength)
|
|
||||||
{
|
{
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return text.left(MaxErrorBodyLength) + QStringLiteral("...");
|
return text.left(MaxDiagnosticBodyLength) + QStringLiteral("...");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString oneLine(QString text)
|
||||||
|
{
|
||||||
|
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
|
||||||
|
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
||||||
|
text.replace(QLatin1Char('\t'), QLatin1Char(' '));
|
||||||
|
return text.simplified();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSensitiveQueryName(const QString &name)
|
||||||
|
{
|
||||||
|
const QString lowerName = name.toLower();
|
||||||
|
return lowerName.contains(QStringLiteral("key"))
|
||||||
|
|| lowerName.contains(QStringLiteral("token"))
|
||||||
|
|| lowerName.contains(QStringLiteral("secret"))
|
||||||
|
|| lowerName.contains(QStringLiteral("password"))
|
||||||
|
|| lowerName.contains(QStringLiteral("authorization"))
|
||||||
|
|| lowerName.contains(QStringLiteral("signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString sanitizedUrlString(QUrl url)
|
||||||
|
{
|
||||||
|
url.setUserInfo(QString());
|
||||||
|
|
||||||
|
QUrlQuery query(url);
|
||||||
|
if (!query.isEmpty())
|
||||||
|
{
|
||||||
|
QUrlQuery sanitizedQuery;
|
||||||
|
const auto items = query.queryItems(QUrl::FullyDecoded);
|
||||||
|
for (const auto &item : items)
|
||||||
|
{
|
||||||
|
sanitizedQuery.addQueryItem(
|
||||||
|
item.first,
|
||||||
|
isSensitiveQueryName(item.first) ? QStringLiteral("<redacted>") : item.second);
|
||||||
|
}
|
||||||
|
url.setQuery(sanitizedQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString(QUrl::FullyEncoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString diagnosticContext(const AIConfig &config, const QUrl &url)
|
||||||
|
{
|
||||||
|
return QStringLiteral("provider=%1 protocol=%2 model=%3 url=%4 timeoutMs=%5 maxTokens=%6 temperature=%7")
|
||||||
|
.arg(config.provider.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.provider.trimmed())
|
||||||
|
.arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.protocol.trimmed())
|
||||||
|
.arg(config.model.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.model.trimmed())
|
||||||
|
.arg(sanitizedUrlString(url))
|
||||||
|
.arg(config.timeoutMs)
|
||||||
|
.arg(config.maxTokens)
|
||||||
|
.arg(QString::number(config.temperature, 'f', 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
QString errorMessageFromBody(const QByteArray &body)
|
QString errorMessageFromBody(const QByteArray &body)
|
||||||
@@ -113,6 +169,23 @@ bool OpenAICompatibleProvider::isBusy() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, ResponseCallback callback)
|
void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, ResponseCallback callback)
|
||||||
|
{
|
||||||
|
sendChatRequestInternal(request, false, nullptr, std::move(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenAICompatibleProvider::sendStreamingChatRequest(
|
||||||
|
const ChatRequest &request,
|
||||||
|
StreamCallback streamCallback,
|
||||||
|
ResponseCallback callback)
|
||||||
|
{
|
||||||
|
sendChatRequestInternal(request, true, std::move(streamCallback), std::move(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenAICompatibleProvider::sendChatRequestInternal(
|
||||||
|
const ChatRequest &request,
|
||||||
|
bool stream,
|
||||||
|
StreamCallback streamCallback,
|
||||||
|
ResponseCallback callback)
|
||||||
{
|
{
|
||||||
if (isBusy())
|
if (isBusy())
|
||||||
{
|
{
|
||||||
@@ -151,13 +224,34 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_callback = std::move(callback);
|
m_callback = std::move(callback);
|
||||||
|
m_streamCallback = std::move(streamCallback);
|
||||||
|
m_streaming = stream;
|
||||||
|
m_streamDone = false;
|
||||||
|
m_streamBuffer.clear();
|
||||||
|
m_streamRawBody.clear();
|
||||||
|
m_streamedContent.clear();
|
||||||
|
|
||||||
QNetworkRequest networkRequest(requestUrl());
|
const QUrl url = requestUrl();
|
||||||
|
QNetworkRequest networkRequest(url);
|
||||||
networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||||
networkRequest.setRawHeader("Authorization", QByteArray("Bearer ") + m_config.apiKey.toUtf8());
|
networkRequest.setRawHeader("Authorization", QByteArray("Bearer ") + m_config.apiKey.toUtf8());
|
||||||
|
|
||||||
const QJsonDocument document(buildPayload(request));
|
const QJsonDocument document(buildPayload(request, stream));
|
||||||
m_currentReply = m_networkManager.post(networkRequest, document.toJson(QJsonDocument::Compact));
|
const QByteArray payload = document.toJson(QJsonDocument::Compact);
|
||||||
|
Logger::info(QStringLiteral("AI request started: %1 stream=%2 messageCount=%3 payloadBytes=%4")
|
||||||
|
.arg(diagnosticContext(m_config, url))
|
||||||
|
.arg(stream ? QStringLiteral("true") : QStringLiteral("false"))
|
||||||
|
.arg(QString::number(request.messages.size()))
|
||||||
|
.arg(QString::number(payload.size())));
|
||||||
|
|
||||||
|
m_currentReply = m_networkManager.post(networkRequest, payload);
|
||||||
|
if (stream)
|
||||||
|
{
|
||||||
|
m_readyReadConnection = QObject::connect(m_currentReply, &QNetworkReply::readyRead, [this]() {
|
||||||
|
handleStreamReadyRead();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() {
|
m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() {
|
||||||
if (m_currentReply.isNull())
|
if (m_currentReply.isNull())
|
||||||
{
|
{
|
||||||
@@ -166,7 +260,37 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo
|
|||||||
|
|
||||||
QNetworkReply *reply = m_currentReply;
|
QNetworkReply *reply = m_currentReply;
|
||||||
const QByteArray body = reply->readAll();
|
const QByteArray body = reply->readAll();
|
||||||
ChatResponse response = parseResponse(reply, body);
|
ChatResponse response;
|
||||||
|
if (m_streaming)
|
||||||
|
{
|
||||||
|
if (!body.isEmpty())
|
||||||
|
{
|
||||||
|
m_streamRawBody.append(body);
|
||||||
|
}
|
||||||
|
if (reply->error() == QNetworkReply::NoError && !body.isEmpty())
|
||||||
|
{
|
||||||
|
m_streamBuffer.append(body);
|
||||||
|
processStreamBuffer();
|
||||||
|
if (m_currentReply.isNull())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reply->error() == QNetworkReply::NoError && !m_streamBuffer.isEmpty())
|
||||||
|
{
|
||||||
|
m_streamBuffer.append('\n');
|
||||||
|
processStreamBuffer();
|
||||||
|
if (m_currentReply.isNull())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = finishStreamingResponse(reply, body);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response = parseResponse(reply, body);
|
||||||
|
}
|
||||||
clearReply();
|
clearReply();
|
||||||
|
|
||||||
if (m_callback)
|
if (m_callback)
|
||||||
@@ -184,6 +308,11 @@ void OpenAICompatibleProvider::cancel()
|
|||||||
{
|
{
|
||||||
m_callback = nullptr;
|
m_callback = nullptr;
|
||||||
QPointer<QNetworkReply> reply = m_currentReply;
|
QPointer<QNetworkReply> reply = m_currentReply;
|
||||||
|
if (!reply.isNull())
|
||||||
|
{
|
||||||
|
Logger::info(QStringLiteral("AI request canceled: %1")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url())));
|
||||||
|
}
|
||||||
clearReply();
|
clearReply();
|
||||||
|
|
||||||
if (!reply.isNull())
|
if (!reply.isNull())
|
||||||
@@ -192,7 +321,7 @@ void OpenAICompatibleProvider::cancel()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request) const
|
QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request, bool stream) const
|
||||||
{
|
{
|
||||||
QJsonArray messages;
|
QJsonArray messages;
|
||||||
for (const ChatMessage &message : request.messages)
|
for (const ChatMessage &message : request.messages)
|
||||||
@@ -206,7 +335,7 @@ QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request) c
|
|||||||
QJsonObject payload;
|
QJsonObject payload;
|
||||||
payload.insert(QStringLiteral("model"), m_config.model);
|
payload.insert(QStringLiteral("model"), m_config.model);
|
||||||
payload.insert(QStringLiteral("messages"), messages);
|
payload.insert(QStringLiteral("messages"), messages);
|
||||||
payload.insert(QStringLiteral("stream"), false);
|
payload.insert(QStringLiteral("stream"), stream);
|
||||||
payload.insert(QStringLiteral("temperature"), m_config.temperature);
|
payload.insert(QStringLiteral("temperature"), m_config.temperature);
|
||||||
payload.insert(QStringLiteral("max_tokens"), m_config.maxTokens);
|
payload.insert(QStringLiteral("max_tokens"), m_config.maxTokens);
|
||||||
return payload;
|
return payload;
|
||||||
@@ -230,12 +359,113 @@ QUrl OpenAICompatibleProvider::requestUrl() const
|
|||||||
return QUrl(baseUrl + path);
|
return QUrl(baseUrl + path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OpenAICompatibleProvider::handleStreamReadyRead()
|
||||||
|
{
|
||||||
|
if (m_currentReply.isNull())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray chunk = m_currentReply->readAll();
|
||||||
|
m_streamRawBody.append(chunk);
|
||||||
|
m_streamBuffer.append(chunk);
|
||||||
|
processStreamBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenAICompatibleProvider::processStreamBuffer()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
const int lineEnd = m_streamBuffer.indexOf('\n');
|
||||||
|
if (lineEnd < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray line = m_streamBuffer.left(lineEnd);
|
||||||
|
m_streamBuffer.remove(0, lineEnd + 1);
|
||||||
|
line = line.trimmed();
|
||||||
|
if (line.isEmpty() || line.startsWith(':'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.startsWith("data:"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray payload = line.mid(5).trimmed();
|
||||||
|
if (!handleStreamPayload(payload))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload)
|
||||||
|
{
|
||||||
|
if (payload == "[DONE]")
|
||||||
|
{
|
||||||
|
m_streamDone = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonParseError parseError;
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError);
|
||||||
|
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3")
|
||||||
|
.arg(diagnosticContext(m_config, requestUrl()))
|
||||||
|
.arg(oneLine(parseError.errorString()))
|
||||||
|
.arg(QString::number(payload.size())));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject root = document.object();
|
||||||
|
if (root.contains(QStringLiteral("error")))
|
||||||
|
{
|
||||||
|
const QString bodyError = errorMessageFromBody(payload);
|
||||||
|
finishWithError(bodyError.isEmpty() ? QStringLiteral("AI stream returned an error.") : bodyError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonArray choices = root.value(QStringLiteral("choices")).toArray();
|
||||||
|
if (choices.isEmpty() || !choices.first().isObject())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject choice = choices.first().toObject();
|
||||||
|
const QJsonObject delta = choice.value(QStringLiteral("delta")).toObject();
|
||||||
|
const QString content = delta.value(QStringLiteral("content")).toString();
|
||||||
|
if (content.isEmpty())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_streamedContent += content;
|
||||||
|
if (m_streamCallback)
|
||||||
|
{
|
||||||
|
m_streamCallback(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const QByteArray &body) const
|
ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const QByteArray &body) const
|
||||||
{
|
{
|
||||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
if (reply->error() != QNetworkReply::NoError)
|
if (reply->error() != QNetworkReply::NoError)
|
||||||
{
|
{
|
||||||
const QString bodyError = errorMessageFromBody(body);
|
const QString bodyError = errorMessageFromBody(body);
|
||||||
|
Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\"")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(static_cast<int>(reply->error()))
|
||||||
|
.arg(oneLine(reply->errorString()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(oneLine(trimmedResponseBody(body))));
|
||||||
|
|
||||||
if (!bodyError.isEmpty())
|
if (!bodyError.isEmpty())
|
||||||
{
|
{
|
||||||
return {false, {}, bodyError, httpStatus};
|
return {false, {}, bodyError, httpStatus};
|
||||||
@@ -248,6 +478,11 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
|
|||||||
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
|
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
|
||||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||||
{
|
{
|
||||||
|
Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" body=\"%4\"")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(oneLine(parseError.errorString()))
|
||||||
|
.arg(oneLine(trimmedResponseBody(body))));
|
||||||
return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus};
|
return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +490,10 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
|
|||||||
const QJsonArray choices = root.value(QStringLiteral("choices")).toArray();
|
const QJsonArray choices = root.value(QStringLiteral("choices")).toArray();
|
||||||
if (choices.isEmpty() || !choices.first().isObject())
|
if (choices.isEmpty() || !choices.first().isObject())
|
||||||
{
|
{
|
||||||
|
Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 body=\"%3\"")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(oneLine(trimmedResponseBody(body))));
|
||||||
return {false, {}, QStringLiteral("AI response has no choices."), httpStatus};
|
return {false, {}, QStringLiteral("AI response has no choices."), httpStatus};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,15 +501,76 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
|
|||||||
const QString content = message.value(QStringLiteral("content")).toString();
|
const QString content = message.value(QStringLiteral("content")).toString();
|
||||||
if (content.isEmpty())
|
if (content.isEmpty())
|
||||||
{
|
{
|
||||||
|
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 body=\"%3\"")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(oneLine(trimmedResponseBody(body))));
|
||||||
return {false, {}, QStringLiteral("AI response content is empty."), httpStatus};
|
return {false, {}, QStringLiteral("AI response content is empty."), httpStatus};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger::info(QStringLiteral("AI request completed: %1 httpStatus=%2 responseChars=%3 responseBytes=%4")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(QString::number(content.size()))
|
||||||
|
.arg(QString::number(body.size())));
|
||||||
|
|
||||||
return {true, content, {}, httpStatus};
|
return {true, content, {}, httpStatus};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *reply, const QByteArray &body) const
|
||||||
|
{
|
||||||
|
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
const QByteArray diagnosticBody = m_streamedContent.isEmpty()
|
||||||
|
? (m_streamRawBody.isEmpty() ? body : m_streamRawBody)
|
||||||
|
: QByteArray();
|
||||||
|
if (reply->error() != QNetworkReply::NoError)
|
||||||
|
{
|
||||||
|
const QString bodyError = errorMessageFromBody(diagnosticBody);
|
||||||
|
Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\" streamedChars=%6")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(static_cast<int>(reply->error()))
|
||||||
|
.arg(oneLine(reply->errorString()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(oneLine(trimmedResponseBody(diagnosticBody)))
|
||||||
|
.arg(QString::number(m_streamedContent.size())));
|
||||||
|
|
||||||
|
if (!bodyError.isEmpty())
|
||||||
|
{
|
||||||
|
return {false, {}, bodyError, httpStatus};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {false, {}, reply->errorString(), httpStatus};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_streamedContent.isEmpty())
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 body=\"%5\"")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
|
||||||
|
.arg(QString::number(m_streamBuffer.size()))
|
||||||
|
.arg(oneLine(trimmedResponseBody(diagnosticBody))));
|
||||||
|
return {false, {}, QStringLiteral("AI streaming response content is empty."), httpStatus};
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::info(QStringLiteral("AI streaming request completed: %1 httpStatus=%2 streamDone=%3 responseChars=%4")
|
||||||
|
.arg(diagnosticContext(m_config, reply->request().url()))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
|
||||||
|
.arg(QString::number(m_streamedContent.size())));
|
||||||
|
|
||||||
|
return {true, m_streamedContent, {}, httpStatus};
|
||||||
|
}
|
||||||
|
|
||||||
void OpenAICompatibleProvider::finishWithError(const QString &message, int httpStatus)
|
void OpenAICompatibleProvider::finishWithError(const QString &message, int httpStatus)
|
||||||
{
|
{
|
||||||
QPointer<QNetworkReply> reply = m_currentReply;
|
QPointer<QNetworkReply> reply = m_currentReply;
|
||||||
|
const QUrl url = reply.isNull() ? requestUrl() : reply->request().url();
|
||||||
|
Logger::warning(QStringLiteral("AI request finished with error: %1 httpStatus=%2 error=\"%3\"")
|
||||||
|
.arg(diagnosticContext(m_config, url))
|
||||||
|
.arg(httpStatus)
|
||||||
|
.arg(oneLine(message)));
|
||||||
|
|
||||||
clearReply();
|
clearReply();
|
||||||
|
|
||||||
if (!reply.isNull())
|
if (!reply.isNull())
|
||||||
@@ -294,10 +594,22 @@ void OpenAICompatibleProvider::clearReply()
|
|||||||
QObject::disconnect(m_replyFinishedConnection);
|
QObject::disconnect(m_replyFinishedConnection);
|
||||||
m_replyFinishedConnection = {};
|
m_replyFinishedConnection = {};
|
||||||
}
|
}
|
||||||
|
if (m_readyReadConnection)
|
||||||
|
{
|
||||||
|
QObject::disconnect(m_readyReadConnection);
|
||||||
|
m_readyReadConnection = {};
|
||||||
|
}
|
||||||
|
|
||||||
if (!m_currentReply.isNull())
|
if (!m_currentReply.isNull())
|
||||||
{
|
{
|
||||||
m_currentReply->deleteLater();
|
m_currentReply->deleteLater();
|
||||||
m_currentReply.clear();
|
m_currentReply.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_streamCallback = nullptr;
|
||||||
|
m_streamBuffer.clear();
|
||||||
|
m_streamRawBody.clear();
|
||||||
|
m_streamedContent.clear();
|
||||||
|
m_streaming = false;
|
||||||
|
m_streamDone = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "LLMProvider.h"
|
#include "LLMProvider.h"
|
||||||
#include "../config/AIConfig.h"
|
#include "../config/AIConfig.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
@@ -20,12 +21,25 @@ public:
|
|||||||
|
|
||||||
bool isBusy() const override;
|
bool isBusy() const override;
|
||||||
void sendChatRequest(const ChatRequest &request, ResponseCallback callback) override;
|
void sendChatRequest(const ChatRequest &request, ResponseCallback callback) override;
|
||||||
|
void sendStreamingChatRequest(
|
||||||
|
const ChatRequest &request,
|
||||||
|
StreamCallback streamCallback,
|
||||||
|
ResponseCallback callback) override;
|
||||||
void cancel() override;
|
void cancel() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QJsonObject buildPayload(const ChatRequest &request) const;
|
QJsonObject buildPayload(const ChatRequest &request, bool stream) const;
|
||||||
QUrl requestUrl() const;
|
QUrl requestUrl() const;
|
||||||
ChatResponse parseResponse(QNetworkReply *reply, const QByteArray &body) const;
|
ChatResponse parseResponse(QNetworkReply *reply, const QByteArray &body) const;
|
||||||
|
void sendChatRequestInternal(
|
||||||
|
const ChatRequest &request,
|
||||||
|
bool stream,
|
||||||
|
StreamCallback streamCallback,
|
||||||
|
ResponseCallback callback);
|
||||||
|
void handleStreamReadyRead();
|
||||||
|
void processStreamBuffer();
|
||||||
|
bool handleStreamPayload(const QByteArray &payload);
|
||||||
|
ChatResponse finishStreamingResponse(QNetworkReply *reply, const QByteArray &body) const;
|
||||||
void finishWithError(const QString &message, int httpStatus = 0);
|
void finishWithError(const QString &message, int httpStatus = 0);
|
||||||
void clearReply();
|
void clearReply();
|
||||||
|
|
||||||
@@ -33,6 +47,13 @@ private:
|
|||||||
QNetworkAccessManager m_networkManager;
|
QNetworkAccessManager m_networkManager;
|
||||||
QPointer<QNetworkReply> m_currentReply;
|
QPointer<QNetworkReply> m_currentReply;
|
||||||
QMetaObject::Connection m_replyFinishedConnection;
|
QMetaObject::Connection m_replyFinishedConnection;
|
||||||
|
QMetaObject::Connection m_readyReadConnection;
|
||||||
QTimer m_timeoutTimer;
|
QTimer m_timeoutTimer;
|
||||||
ResponseCallback m_callback;
|
ResponseCallback m_callback;
|
||||||
|
StreamCallback m_streamCallback;
|
||||||
|
QByteArray m_streamBuffer;
|
||||||
|
QByteArray m_streamRawBody;
|
||||||
|
QString m_streamedContent;
|
||||||
|
bool m_streaming = false;
|
||||||
|
bool m_streamDone = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,14 +69,13 @@ ChatBubble::ChatBubble(QWidget *parent)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs)
|
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom)
|
||||||
{
|
{
|
||||||
m_dismissOnExternalInteraction = false;
|
m_dismissOnExternalInteraction = false;
|
||||||
m_anchorPosition = anchorPosition;
|
m_anchorPosition = anchorPosition;
|
||||||
m_autoHideDurationMs = durationMs;
|
m_autoHideDurationMs = durationMs;
|
||||||
const QString trimmed = message.trimmed();
|
const QString trimmed = message.trimmed();
|
||||||
m_textEdit->setPlainText(trimmed);
|
m_textEdit->setPlainText(trimmed);
|
||||||
m_textEdit->verticalScrollBar()->setValue(0);
|
|
||||||
|
|
||||||
const QSize bubbleSize = preferredBubbleSize(trimmed);
|
const QSize bubbleSize = preferredBubbleSize(trimmed);
|
||||||
m_textEdit->setFixedSize(bubbleSize);
|
m_textEdit->setFixedSize(bubbleSize);
|
||||||
@@ -84,10 +83,17 @@ void ChatBubble::showMessage(const QString &message, const QPoint &anchorPositio
|
|||||||
updatePosition();
|
updatePosition();
|
||||||
show();
|
show();
|
||||||
|
|
||||||
|
m_textEdit->verticalScrollBar()->setValue(
|
||||||
|
scrollToBottom ? m_textEdit->verticalScrollBar()->maximum() : 0);
|
||||||
|
|
||||||
if (durationMs > 0)
|
if (durationMs > 0)
|
||||||
{
|
{
|
||||||
m_hideTimer.start(durationMs);
|
m_hideTimer.start(durationMs);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
m_hideTimer.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition)
|
void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition)
|
||||||
|
|||||||
+5
-1
@@ -11,7 +11,11 @@ class ChatBubble : public QWidget
|
|||||||
public:
|
public:
|
||||||
explicit ChatBubble(QWidget *parent = nullptr);
|
explicit ChatBubble(QWidget *parent = nullptr);
|
||||||
|
|
||||||
void showMessage(const QString &message, const QPoint &anchorPosition, int durationMs = 10000);
|
void showMessage(
|
||||||
|
const QString &message,
|
||||||
|
const QPoint &anchorPosition,
|
||||||
|
int durationMs = 10000,
|
||||||
|
bool scrollToBottom = false);
|
||||||
void updateAnchorPosition(const QPoint &anchorPosition);
|
void updateAnchorPosition(const QPoint &anchorPosition);
|
||||||
void setDismissOnExternalInteraction(bool enabled);
|
void setDismissOnExternalInteraction(bool enabled);
|
||||||
void resetAutoHideTimer();
|
void resetAutoHideTimer();
|
||||||
|
|||||||
+77
-124
@@ -44,6 +44,7 @@ QString previewImagePath()
|
|||||||
|
|
||||||
constexpr int MaxUserMessageLength = 4000;
|
constexpr int MaxUserMessageLength = 4000;
|
||||||
constexpr int ChatInputLowerOffsetY = 48;
|
constexpr int ChatInputLowerOffsetY = 48;
|
||||||
|
constexpr int StreamBubbleUpdateIntervalMs = 80;
|
||||||
|
|
||||||
bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
|
bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
|
||||||
{
|
{
|
||||||
@@ -163,6 +164,11 @@ PetWindow::PetWindow(QWidget *parent)
|
|||||||
returnToIdleFromBehavior();
|
returnToIdleFromBehavior();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
m_streamBubbleUpdateTimer.setSingleShot(true);
|
||||||
|
connect(&m_streamBubbleUpdateTimer, &QTimer::timeout, this, [this]() {
|
||||||
|
flushStreamingBubble(false);
|
||||||
|
});
|
||||||
|
|
||||||
QPointer<PetWindow> window(this);
|
QPointer<PetWindow> window(this);
|
||||||
m_chatInputDialog->setSubmitCallback([window](const QString &message) {
|
m_chatInputDialog->setSubmitCallback([window](const QString &message) {
|
||||||
return !window.isNull() && window->submitChatMessage(message);
|
return !window.isNull() && window->submitChatMessage(message);
|
||||||
@@ -231,14 +237,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
topAction->setCheckable(true);
|
topAction->setCheckable(true);
|
||||||
topAction->setChecked(m_alwaysOnTop);
|
topAction->setChecked(m_alwaysOnTop);
|
||||||
|
|
||||||
QMenu *bubbleTestMenu = menu.addMenu(QStringLiteral("气泡测试"));
|
|
||||||
QAction *shortBubbleAction = bubbleTestMenu->addAction(QStringLiteral("短文本"));
|
|
||||||
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
|
||||||
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
|
||||||
|
|
||||||
const bool aiRequestRunning = hasActiveAIRequest();
|
const bool aiRequestRunning = hasActiveAIRequest();
|
||||||
QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试"));
|
|
||||||
aiTestAction->setEnabled(!aiRequestRunning);
|
|
||||||
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
|
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
|
||||||
chatAction->setEnabled(!aiRequestRunning);
|
chatAction->setEnabled(!aiRequestRunning);
|
||||||
QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话"));
|
QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话"));
|
||||||
@@ -258,22 +257,6 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
{
|
{
|
||||||
setAlwaysOnTop(!m_alwaysOnTop);
|
setAlwaysOnTop(!m_alwaysOnTop);
|
||||||
}
|
}
|
||||||
else if (selectedAction == shortBubbleAction)
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("收到,马上处理。"));
|
|
||||||
}
|
|
||||||
else if (selectedAction == maxBubbleAction)
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("这是一段用于测试气泡最大显示区域附近表现的文本。它应该自动换行,并在不出现滚动条的情况下尽量接近最大宽度和高度,方便观察边距、圆角、阴影和整体位置是否自然。"));
|
|
||||||
}
|
|
||||||
else if (selectedAction == scrollBubbleAction)
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。"));
|
|
||||||
}
|
|
||||||
else if (selectedAction == aiTestAction)
|
|
||||||
{
|
|
||||||
startAITest();
|
|
||||||
}
|
|
||||||
else if (selectedAction == chatAction)
|
else if (selectedAction == chatAction)
|
||||||
{
|
{
|
||||||
startChat();
|
startChat();
|
||||||
@@ -309,65 +292,8 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PetWindow::startAITest()
|
|
||||||
{
|
|
||||||
if (m_aiTestProvider && m_aiTestProvider->isBusy())
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_conversationManager && m_conversationManager->isBusy())
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigManager configManager;
|
|
||||||
AIConfig config = configManager.loadAIConfig();
|
|
||||||
QString errorMessage;
|
|
||||||
if (!prepareRuntimeAIConfig(config, &errorMessage))
|
|
||||||
{
|
|
||||||
playState(QStringLiteral("error"), false);
|
|
||||||
showBubbleMessage(errorMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatRequest request;
|
|
||||||
request.messages.append({QStringLiteral("system"), QStringLiteral("你是桌宠的最小连通性测试助手。")});
|
|
||||||
request.messages.append({QStringLiteral("user"), QStringLiteral("请用一句话回复测试成功。")});
|
|
||||||
|
|
||||||
m_aiTestProvider = std::make_unique<OpenAICompatibleProvider>(config);
|
|
||||||
playState(QStringLiteral("think"), false);
|
|
||||||
showBubbleMessage(QStringLiteral("正在测试 AI 连接..."));
|
|
||||||
|
|
||||||
QPointer<PetWindow> window(this);
|
|
||||||
m_aiTestProvider->sendChatRequest(request, [window](const ChatResponse &response) {
|
|
||||||
if (window.isNull())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success)
|
|
||||||
{
|
|
||||||
window->playState(QStringLiteral("talk"), false);
|
|
||||||
window->showBubbleMessage(response.content);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window->playState(QStringLiteral("error"), false);
|
|
||||||
window->showBubbleMessage(QStringLiteral("AI 测试失败:") + userVisibleErrorMessage(response));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void PetWindow::startChat()
|
void PetWindow::startChat()
|
||||||
{
|
{
|
||||||
if (m_aiTestProvider && m_aiTestProvider->isBusy())
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_chatInputDialog)
|
if (!m_chatInputDialog)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -378,12 +304,6 @@ void PetWindow::startChat()
|
|||||||
|
|
||||||
bool PetWindow::submitChatMessage(const QString &message)
|
bool PetWindow::submitChatMessage(const QString &message)
|
||||||
{
|
{
|
||||||
if (m_aiTestProvider && m_aiTestProvider->isBusy())
|
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_conversationManager || m_conversationManager->isBusy())
|
if (!m_conversationManager || m_conversationManager->isBusy())
|
||||||
{
|
{
|
||||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||||
@@ -420,26 +340,39 @@ bool PetWindow::submitChatMessage(const QString &message)
|
|||||||
}
|
}
|
||||||
|
|
||||||
playState(QStringLiteral("think"), false);
|
playState(QStringLiteral("think"), false);
|
||||||
showBubbleMessage(QStringLiteral("正在思考..."));
|
m_streamingAssistantText.clear();
|
||||||
|
m_streamBubbleUpdateTimer.stop();
|
||||||
|
m_chatBubble->showMessage(QStringLiteral("正在思考..."), bubbleAnchorPosition(), 0);
|
||||||
|
|
||||||
QPointer<PetWindow> window(this);
|
QPointer<PetWindow> window(this);
|
||||||
m_conversationManager->sendUserMessage(message, [window](const ChatResponse &response) {
|
m_conversationManager->sendUserMessageStreaming(
|
||||||
if (window.isNull())
|
trimmedMessage,
|
||||||
{
|
[window](const QString &delta) {
|
||||||
return;
|
if (!window.isNull())
|
||||||
}
|
{
|
||||||
|
window->handleChatStreamDelta(delta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[window](const ChatResponse &response) {
|
||||||
|
if (window.isNull())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.success)
|
window->m_streamBubbleUpdateTimer.stop();
|
||||||
{
|
if (response.success)
|
||||||
window->playState(QStringLiteral("talk"), false);
|
{
|
||||||
window->showBubbleMessage(response.content);
|
window->m_streamingAssistantText = response.content;
|
||||||
window->refreshChatHistoryPanel();
|
window->flushStreamingBubble(true);
|
||||||
return;
|
window->playState(QStringLiteral("talk"), false);
|
||||||
}
|
window->refreshChatHistoryPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window->playState(QStringLiteral("error"), false);
|
window->m_streamingAssistantText.clear();
|
||||||
window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response));
|
window->playState(QStringLiteral("error"), false);
|
||||||
});
|
window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response));
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -452,12 +385,9 @@ void PetWindow::clearConversation()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bool hadActiveRequest = hasActiveAIRequest();
|
const bool hadActiveRequest = hasActiveAIRequest();
|
||||||
if (m_aiTestProvider && m_aiTestProvider->isBusy())
|
|
||||||
{
|
|
||||||
m_aiTestProvider->cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
m_conversationManager->clear();
|
m_conversationManager->clear();
|
||||||
|
m_streamBubbleUpdateTimer.stop();
|
||||||
|
m_streamingAssistantText.clear();
|
||||||
refreshChatHistoryPanel();
|
refreshChatHistoryPanel();
|
||||||
showBubbleMessage(hadActiveRequest
|
showBubbleMessage(hadActiveRequest
|
||||||
? QStringLiteral("已取消 AI 请求,并清空对话。")
|
? QStringLiteral("已取消 AI 请求,并清空对话。")
|
||||||
@@ -467,33 +397,22 @@ void PetWindow::clearConversation()
|
|||||||
|
|
||||||
void PetWindow::cancelActiveAIRequest()
|
void PetWindow::cancelActiveAIRequest()
|
||||||
{
|
{
|
||||||
bool canceled = false;
|
|
||||||
if (m_aiTestProvider && m_aiTestProvider->isBusy())
|
|
||||||
{
|
|
||||||
m_aiTestProvider->cancel();
|
|
||||||
canceled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_conversationManager && m_conversationManager->isBusy())
|
if (m_conversationManager && m_conversationManager->isBusy())
|
||||||
{
|
{
|
||||||
m_conversationManager->cancel();
|
m_conversationManager->cancel();
|
||||||
canceled = true;
|
m_streamBubbleUpdateTimer.stop();
|
||||||
}
|
m_streamingAssistantText.clear();
|
||||||
|
showBubbleMessage(QStringLiteral("AI 请求已取消。"));
|
||||||
if (!canceled)
|
playState(QStringLiteral("idle"), false);
|
||||||
{
|
|
||||||
showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showBubbleMessage(QStringLiteral("AI 请求已取消。"));
|
showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。"));
|
||||||
playState(QStringLiteral("idle"), false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PetWindow::hasActiveAIRequest() const
|
bool PetWindow::hasActiveAIRequest() const
|
||||||
{
|
{
|
||||||
return (m_aiTestProvider && m_aiTestProvider->isBusy())
|
return m_conversationManager && m_conversationManager->isBusy();
|
||||||
|| (m_conversationManager && m_conversationManager->isBusy());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PetWindow::showConversationHistory()
|
void PetWindow::showConversationHistory()
|
||||||
@@ -514,6 +433,39 @@ void PetWindow::refreshChatHistoryPanel()
|
|||||||
m_chatHistoryPanel->setMessages(history);
|
m_chatHistoryPanel->setMessages(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PetWindow::handleChatStreamDelta(const QString &delta)
|
||||||
|
{
|
||||||
|
if (delta.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_streamingAssistantText += delta;
|
||||||
|
if (!isVisible())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_streamBubbleUpdateTimer.isActive())
|
||||||
|
{
|
||||||
|
m_streamBubbleUpdateTimer.start(StreamBubbleUpdateIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PetWindow::flushStreamingBubble(bool finalUpdate)
|
||||||
|
{
|
||||||
|
if (!isVisible() || m_streamingAssistantText.trimmed().isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_chatBubble->showMessage(
|
||||||
|
m_streamingAssistantText,
|
||||||
|
bubbleAnchorPosition(),
|
||||||
|
finalUpdate ? 10000 : 0,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
void PetWindow::resetBubbleAutoHideTimer()
|
void PetWindow::resetBubbleAutoHideTimer()
|
||||||
{
|
{
|
||||||
if (m_chatBubble)
|
if (m_chatBubble)
|
||||||
@@ -529,6 +481,7 @@ QPoint PetWindow::chatInputAnchorPosition() const
|
|||||||
|
|
||||||
void PetWindow::hideEvent(QHideEvent *event)
|
void PetWindow::hideEvent(QHideEvent *event)
|
||||||
{
|
{
|
||||||
|
m_streamBubbleUpdateTimer.stop();
|
||||||
if (m_chatBubble)
|
if (m_chatBubble)
|
||||||
{
|
{
|
||||||
m_chatBubble->hideBubble();
|
m_chatBubble->hideBubble();
|
||||||
|
|||||||
+4
-3
@@ -21,7 +21,6 @@ class ChatBubble;
|
|||||||
class ChatHistoryPanel;
|
class ChatHistoryPanel;
|
||||||
class ChatInputDialog;
|
class ChatInputDialog;
|
||||||
class ConversationManager;
|
class ConversationManager;
|
||||||
class LLMProvider;
|
|
||||||
class PetView;
|
class PetView;
|
||||||
|
|
||||||
class PetWindow : public QWidget
|
class PetWindow : public QWidget
|
||||||
@@ -49,13 +48,14 @@ private:
|
|||||||
void loadInitialImage();
|
void loadInitialImage();
|
||||||
void buildAnimationClips();
|
void buildAnimationClips();
|
||||||
void addStateTestActions(QMenu *menu);
|
void addStateTestActions(QMenu *menu);
|
||||||
void startAITest();
|
|
||||||
void startChat();
|
void startChat();
|
||||||
bool submitChatMessage(const QString &message);
|
bool submitChatMessage(const QString &message);
|
||||||
void clearConversation();
|
void clearConversation();
|
||||||
void cancelActiveAIRequest();
|
void cancelActiveAIRequest();
|
||||||
void showConversationHistory();
|
void showConversationHistory();
|
||||||
void refreshChatHistoryPanel();
|
void refreshChatHistoryPanel();
|
||||||
|
void handleChatStreamDelta(const QString &delta);
|
||||||
|
void flushStreamingBubble(bool finalUpdate);
|
||||||
bool hasActiveAIRequest() const;
|
bool hasActiveAIRequest() const;
|
||||||
void resetBubbleAutoHideTimer();
|
void resetBubbleAutoHideTimer();
|
||||||
QPoint chatInputAnchorPosition() const;
|
QPoint chatInputAnchorPosition() const;
|
||||||
@@ -75,15 +75,16 @@ private:
|
|||||||
std::unique_ptr<ChatHistoryPanel> m_chatHistoryPanel;
|
std::unique_ptr<ChatHistoryPanel> m_chatHistoryPanel;
|
||||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||||
std::unique_ptr<LLMProvider> m_aiTestProvider;
|
|
||||||
PetView *m_petView;
|
PetView *m_petView;
|
||||||
QTimer m_idleBehaviorTimer;
|
QTimer m_idleBehaviorTimer;
|
||||||
QTimer m_behaviorReturnTimer;
|
QTimer m_behaviorReturnTimer;
|
||||||
|
QTimer m_streamBubbleUpdateTimer;
|
||||||
CharacterPackage m_characterPackage;
|
CharacterPackage m_characterPackage;
|
||||||
QMap<QString, AnimationClip> m_clips;
|
QMap<QString, AnimationClip> m_clips;
|
||||||
FrameAnimator m_frameAnimator;
|
FrameAnimator m_frameAnimator;
|
||||||
PetStateMachine m_stateMachine;
|
PetStateMachine m_stateMachine;
|
||||||
QPoint m_dragOffset;
|
QPoint m_dragOffset;
|
||||||
|
QString m_streamingAssistantText;
|
||||||
bool m_dragging;
|
bool m_dragging;
|
||||||
bool m_alwaysOnTop;
|
bool m_alwaysOnTop;
|
||||||
bool m_centerNextFrame;
|
bool m_centerNextFrame;
|
||||||
|
|||||||
Reference in New Issue
Block a user