From 4bf5195bfd880427b6fc581a935cccb8f0c66bca Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Fri, 29 May 2026 21:59:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=81=8A=E5=A4=A9=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/ConversationManager.cpp | 47 ++++ src/ai/ConversationManager.h | 2 + src/ai/LLMProvider.h | 12 + src/ai/OpenAICompatibleProvider.cpp | 330 +++++++++++++++++++++++++++- src/ai/OpenAICompatibleProvider.h | 23 +- src/ui/ChatBubble.cpp | 10 +- src/ui/ChatBubble.h | 6 +- src/ui/PetWindow.cpp | 201 +++++++---------- src/ui/PetWindow.h | 7 +- 9 files changed, 498 insertions(+), 140 deletions(-) diff --git a/src/ai/ConversationManager.cpp b/src/ai/ConversationManager.cpp index 853d323..da8c702 100644 --- a/src/ai/ConversationManager.cpp +++ b/src/ai/ConversationManager.cpp @@ -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() { if (m_provider) diff --git a/src/ai/ConversationManager.h b/src/ai/ConversationManager.h index ebf8a32..ce97cd1 100644 --- a/src/ai/ConversationManager.h +++ b/src/ai/ConversationManager.h @@ -11,6 +11,7 @@ class ConversationManager { public: using ResponseCallback = LLMProvider::ResponseCallback; + using StreamCallback = LLMProvider::StreamCallback; ConversationManager(); ~ConversationManager(); @@ -20,6 +21,7 @@ public: QVector history() const; bool setProvider(std::unique_ptr provider); void sendUserMessage(const QString &message, ResponseCallback callback); + void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback); void cancel(); void clear(); diff --git a/src/ai/LLMProvider.h b/src/ai/LLMProvider.h index d7e0349..01da1f5 100644 --- a/src/ai/LLMProvider.h +++ b/src/ai/LLMProvider.h @@ -2,16 +2,28 @@ #include "LLMTypes.h" +#include + #include +#include class LLMProvider { public: using ResponseCallback = std::function; + using StreamCallback = std::function; virtual ~LLMProvider() = default; virtual bool isBusy() const = 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; }; diff --git a/src/ai/OpenAICompatibleProvider.cpp b/src/ai/OpenAICompatibleProvider.cpp index 22d842c..7c7ebf2 100644 --- a/src/ai/OpenAICompatibleProvider.cpp +++ b/src/ai/OpenAICompatibleProvider.cpp @@ -1,5 +1,7 @@ #include "OpenAICompatibleProvider.h" +#include "../util/Logger.h" + #include #include #include @@ -8,21 +10,75 @@ #include #include #include +#include #include namespace { +constexpr int MaxDiagnosticBodyLength = 1000; + QString trimmedResponseBody(const QByteArray &body) { const QString text = QString::fromUtf8(body).trimmed(); - constexpr int MaxErrorBodyLength = 1000; - if (text.size() <= MaxErrorBodyLength) + if (text.size() <= MaxDiagnosticBodyLength) { 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("") : 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("") : config.provider.trimmed()) + .arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("") : config.protocol.trimmed()) + .arg(config.model.trimmed().isEmpty() ? QStringLiteral("") : config.model.trimmed()) + .arg(sanitizedUrlString(url)) + .arg(config.timeoutMs) + .arg(config.maxTokens) + .arg(QString::number(config.temperature, 'f', 2)); } QString errorMessageFromBody(const QByteArray &body) @@ -113,6 +169,23 @@ bool OpenAICompatibleProvider::isBusy() const } 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()) { @@ -151,13 +224,34 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo } 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.setRawHeader("Authorization", QByteArray("Bearer ") + m_config.apiKey.toUtf8()); - const QJsonDocument document(buildPayload(request)); - m_currentReply = m_networkManager.post(networkRequest, document.toJson(QJsonDocument::Compact)); + const QJsonDocument document(buildPayload(request, stream)); + 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]() { if (m_currentReply.isNull()) { @@ -166,7 +260,37 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo QNetworkReply *reply = m_currentReply; 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(); if (m_callback) @@ -184,6 +308,11 @@ void OpenAICompatibleProvider::cancel() { m_callback = nullptr; QPointer reply = m_currentReply; + if (!reply.isNull()) + { + Logger::info(QStringLiteral("AI request canceled: %1") + .arg(diagnosticContext(m_config, reply->request().url()))); + } clearReply(); 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; for (const ChatMessage &message : request.messages) @@ -206,7 +335,7 @@ QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request) c QJsonObject payload; payload.insert(QStringLiteral("model"), m_config.model); 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("max_tokens"), m_config.maxTokens); return payload; @@ -230,12 +359,113 @@ QUrl OpenAICompatibleProvider::requestUrl() const 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 { const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (reply->error() != QNetworkReply::NoError) { 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(reply->error())) + .arg(oneLine(reply->errorString())) + .arg(httpStatus) + .arg(oneLine(trimmedResponseBody(body)))); + if (!bodyError.isEmpty()) { return {false, {}, bodyError, httpStatus}; @@ -248,6 +478,11 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); 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}; } @@ -255,6 +490,10 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const const QJsonArray choices = root.value(QStringLiteral("choices")).toArray(); 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}; } @@ -262,15 +501,76 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const const QString content = message.value(QStringLiteral("content")).toString(); 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}; } + 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}; } +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(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) { QPointer 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(); if (!reply.isNull()) @@ -294,10 +594,22 @@ void OpenAICompatibleProvider::clearReply() QObject::disconnect(m_replyFinishedConnection); m_replyFinishedConnection = {}; } + if (m_readyReadConnection) + { + QObject::disconnect(m_readyReadConnection); + m_readyReadConnection = {}; + } if (!m_currentReply.isNull()) { m_currentReply->deleteLater(); m_currentReply.clear(); } + + m_streamCallback = nullptr; + m_streamBuffer.clear(); + m_streamRawBody.clear(); + m_streamedContent.clear(); + m_streaming = false; + m_streamDone = false; } diff --git a/src/ai/OpenAICompatibleProvider.h b/src/ai/OpenAICompatibleProvider.h index daa0ada..c0aa0d1 100644 --- a/src/ai/OpenAICompatibleProvider.h +++ b/src/ai/OpenAICompatibleProvider.h @@ -3,6 +3,7 @@ #include "LLMProvider.h" #include "../config/AIConfig.h" +#include #include #include #include @@ -20,12 +21,25 @@ public: bool isBusy() const override; void sendChatRequest(const ChatRequest &request, ResponseCallback callback) override; + void sendStreamingChatRequest( + const ChatRequest &request, + StreamCallback streamCallback, + ResponseCallback callback) override; void cancel() override; private: - QJsonObject buildPayload(const ChatRequest &request) const; + QJsonObject buildPayload(const ChatRequest &request, bool stream) const; QUrl requestUrl() 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 clearReply(); @@ -33,6 +47,13 @@ private: QNetworkAccessManager m_networkManager; QPointer m_currentReply; QMetaObject::Connection m_replyFinishedConnection; + QMetaObject::Connection m_readyReadConnection; QTimer m_timeoutTimer; ResponseCallback m_callback; + StreamCallback m_streamCallback; + QByteArray m_streamBuffer; + QByteArray m_streamRawBody; + QString m_streamedContent; + bool m_streaming = false; + bool m_streamDone = false; }; diff --git a/src/ui/ChatBubble.cpp b/src/ui/ChatBubble.cpp index 7776635..b69f4d8 100644 --- a/src/ui/ChatBubble.cpp +++ b/src/ui/ChatBubble.cpp @@ -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_anchorPosition = anchorPosition; m_autoHideDurationMs = durationMs; const QString trimmed = message.trimmed(); m_textEdit->setPlainText(trimmed); - m_textEdit->verticalScrollBar()->setValue(0); const QSize bubbleSize = preferredBubbleSize(trimmed); m_textEdit->setFixedSize(bubbleSize); @@ -84,10 +83,17 @@ void ChatBubble::showMessage(const QString &message, const QPoint &anchorPositio updatePosition(); show(); + m_textEdit->verticalScrollBar()->setValue( + scrollToBottom ? m_textEdit->verticalScrollBar()->maximum() : 0); + if (durationMs > 0) { m_hideTimer.start(durationMs); } + else + { + m_hideTimer.stop(); + } } void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition) diff --git a/src/ui/ChatBubble.h b/src/ui/ChatBubble.h index efc3d7d..30f9e1f 100644 --- a/src/ui/ChatBubble.h +++ b/src/ui/ChatBubble.h @@ -11,7 +11,11 @@ class ChatBubble : public QWidget public: 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 setDismissOnExternalInteraction(bool enabled); void resetAutoHideTimer(); diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index dec42c4..286a739 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -44,6 +44,7 @@ QString previewImagePath() constexpr int MaxUserMessageLength = 4000; constexpr int ChatInputLowerOffsetY = 48; +constexpr int StreamBubbleUpdateIntervalMs = 80; bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage) { @@ -163,6 +164,11 @@ PetWindow::PetWindow(QWidget *parent) returnToIdleFromBehavior(); }); + m_streamBubbleUpdateTimer.setSingleShot(true); + connect(&m_streamBubbleUpdateTimer, &QTimer::timeout, this, [this]() { + flushStreamingBubble(false); + }); + QPointer window(this); m_chatInputDialog->setSubmitCallback([window](const QString &message) { return !window.isNull() && window->submitChatMessage(message); @@ -231,14 +237,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) topAction->setCheckable(true); 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(); - QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试")); - aiTestAction->setEnabled(!aiRequestRunning); QAction *chatAction = menu.addAction(QStringLiteral("聊天")); chatAction->setEnabled(!aiRequestRunning); QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话")); @@ -258,22 +257,6 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) { 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) { 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(config); - playState(QStringLiteral("think"), false); - showBubbleMessage(QStringLiteral("正在测试 AI 连接...")); - - QPointer 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() { - if (m_aiTestProvider && m_aiTestProvider->isBusy()) - { - showBubbleMessage(QStringLiteral("AI 测试请求正在进行。")); - return; - } - if (!m_chatInputDialog) { return; @@ -378,12 +304,6 @@ void PetWindow::startChat() bool PetWindow::submitChatMessage(const QString &message) { - if (m_aiTestProvider && m_aiTestProvider->isBusy()) - { - showBubbleMessage(QStringLiteral("AI 测试请求正在进行。")); - return false; - } - if (!m_conversationManager || m_conversationManager->isBusy()) { showBubbleMessage(QStringLiteral("AI 回复正在进行。")); @@ -420,26 +340,39 @@ bool PetWindow::submitChatMessage(const QString &message) } playState(QStringLiteral("think"), false); - showBubbleMessage(QStringLiteral("正在思考...")); + m_streamingAssistantText.clear(); + m_streamBubbleUpdateTimer.stop(); + m_chatBubble->showMessage(QStringLiteral("正在思考..."), bubbleAnchorPosition(), 0); QPointer window(this); - m_conversationManager->sendUserMessage(message, [window](const ChatResponse &response) { - if (window.isNull()) - { - return; - } + m_conversationManager->sendUserMessageStreaming( + trimmedMessage, + [window](const QString &delta) { + if (!window.isNull()) + { + window->handleChatStreamDelta(delta); + } + }, + [window](const ChatResponse &response) { + if (window.isNull()) + { + return; + } - if (response.success) - { - window->playState(QStringLiteral("talk"), false); - window->showBubbleMessage(response.content); - window->refreshChatHistoryPanel(); - return; - } + window->m_streamBubbleUpdateTimer.stop(); + if (response.success) + { + window->m_streamingAssistantText = response.content; + window->flushStreamingBubble(true); + window->playState(QStringLiteral("talk"), false); + window->refreshChatHistoryPanel(); + return; + } - window->playState(QStringLiteral("error"), false); - window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response)); - }); + window->m_streamingAssistantText.clear(); + window->playState(QStringLiteral("error"), false); + window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response)); + }); return true; } @@ -452,12 +385,9 @@ void PetWindow::clearConversation() } const bool hadActiveRequest = hasActiveAIRequest(); - if (m_aiTestProvider && m_aiTestProvider->isBusy()) - { - m_aiTestProvider->cancel(); - } - m_conversationManager->clear(); + m_streamBubbleUpdateTimer.stop(); + m_streamingAssistantText.clear(); refreshChatHistoryPanel(); showBubbleMessage(hadActiveRequest ? QStringLiteral("已取消 AI 请求,并清空对话。") @@ -467,33 +397,22 @@ void PetWindow::clearConversation() void PetWindow::cancelActiveAIRequest() { - bool canceled = false; - if (m_aiTestProvider && m_aiTestProvider->isBusy()) - { - m_aiTestProvider->cancel(); - canceled = true; - } - if (m_conversationManager && m_conversationManager->isBusy()) { m_conversationManager->cancel(); - canceled = true; - } - - if (!canceled) - { - showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。")); + m_streamBubbleUpdateTimer.stop(); + m_streamingAssistantText.clear(); + showBubbleMessage(QStringLiteral("AI 请求已取消。")); + playState(QStringLiteral("idle"), false); return; } - showBubbleMessage(QStringLiteral("AI 请求已取消。")); - playState(QStringLiteral("idle"), false); + showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。")); } bool PetWindow::hasActiveAIRequest() const { - return (m_aiTestProvider && m_aiTestProvider->isBusy()) - || (m_conversationManager && m_conversationManager->isBusy()); + return m_conversationManager && m_conversationManager->isBusy(); } void PetWindow::showConversationHistory() @@ -514,6 +433,39 @@ void PetWindow::refreshChatHistoryPanel() 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() { if (m_chatBubble) @@ -529,6 +481,7 @@ QPoint PetWindow::chatInputAnchorPosition() const void PetWindow::hideEvent(QHideEvent *event) { + m_streamBubbleUpdateTimer.stop(); if (m_chatBubble) { m_chatBubble->hideBubble(); diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 6803a25..344b19c 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -21,7 +21,6 @@ class ChatBubble; class ChatHistoryPanel; class ChatInputDialog; class ConversationManager; -class LLMProvider; class PetView; class PetWindow : public QWidget @@ -49,13 +48,14 @@ private: void loadInitialImage(); void buildAnimationClips(); void addStateTestActions(QMenu *menu); - void startAITest(); void startChat(); bool submitChatMessage(const QString &message); void clearConversation(); void cancelActiveAIRequest(); void showConversationHistory(); void refreshChatHistoryPanel(); + void handleChatStreamDelta(const QString &delta); + void flushStreamingBubble(bool finalUpdate); bool hasActiveAIRequest() const; void resetBubbleAutoHideTimer(); QPoint chatInputAnchorPosition() const; @@ -75,15 +75,16 @@ private: std::unique_ptr m_chatHistoryPanel; std::unique_ptr m_chatInputDialog; std::unique_ptr m_conversationManager; - std::unique_ptr m_aiTestProvider; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; + QTimer m_streamBubbleUpdateTimer; CharacterPackage m_characterPackage; QMap m_clips; FrameAnimator m_frameAnimator; PetStateMachine m_stateMachine; QPoint m_dragOffset; + QString m_streamingAssistantText; bool m_dragging; bool m_alwaysOnTop; bool m_centerNextFrame;