添加 AI 连通性测试入口

This commit is contained in:
2026-05-29 10:38:07 +08:00
parent 6133588327
commit f4c7e4a08b
4 changed files with 232 additions and 10 deletions
+108 -10
View File
@@ -6,10 +6,93 @@
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QUrl>
#include <utility>
namespace
{
QString trimmedResponseBody(const QByteArray &body)
{
const QString text = QString::fromUtf8(body).trimmed();
constexpr int MaxErrorBodyLength = 1000;
if (text.size() <= MaxErrorBodyLength)
{
return text;
}
return text.left(MaxErrorBodyLength) + QStringLiteral("...");
}
QString errorMessageFromBody(const QByteArray &body)
{
if (body.trimmed().isEmpty())
{
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return trimmedResponseBody(body);
}
const QJsonObject root = document.object();
QStringList details;
const QJsonValue errorValue = root.value(QStringLiteral("error"));
if (errorValue.isObject())
{
const QJsonObject error = errorValue.toObject();
const QString message = error.value(QStringLiteral("message")).toString().trimmed();
const QString code = error.value(QStringLiteral("code")).toString().trimmed();
const QString type = error.value(QStringLiteral("type")).toString().trimmed();
if (!message.isEmpty())
{
details.append(message);
}
if (!code.isEmpty())
{
details.append(QStringLiteral("code=") + code);
}
if (!type.isEmpty() && type != code)
{
details.append(QStringLiteral("type=") + type);
}
}
else if (errorValue.isString())
{
const QString error = errorValue.toString().trimmed();
if (!error.isEmpty())
{
details.append(error);
}
}
const QString message = root.value(QStringLiteral("message")).toString().trimmed();
if (!message.isEmpty() && !details.contains(message))
{
details.append(message);
}
const QString requestId = root.value(QStringLiteral("request_id")).toString().trimmed();
if (!requestId.isEmpty())
{
details.append(QStringLiteral("request_id=") + requestId);
}
if (!details.isEmpty())
{
return details.join(QStringLiteral("; "));
}
return trimmedResponseBody(body);
}
}
OpenAICompatibleProvider::OpenAICompatibleProvider(const AIConfig &config)
: m_config(config)
{
@@ -75,7 +158,7 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo
const QJsonDocument document(buildPayload(request));
m_currentReply = m_networkManager.post(networkRequest, document.toJson(QJsonDocument::Compact));
QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() {
m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() {
if (m_currentReply.isNull())
{
return;
@@ -99,13 +182,14 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo
void OpenAICompatibleProvider::cancel()
{
if (!m_currentReply.isNull())
{
m_currentReply->abort();
}
clearReply();
m_callback = nullptr;
QPointer<QNetworkReply> reply = m_currentReply;
clearReply();
if (!reply.isNull())
{
reply->abort();
}
}
QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request) const
@@ -151,6 +235,12 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError)
{
const QString bodyError = errorMessageFromBody(body);
if (!bodyError.isEmpty())
{
return {false, {}, bodyError, httpStatus};
}
return {false, {}, reply->errorString(), httpStatus};
}
@@ -180,12 +270,14 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
void OpenAICompatibleProvider::finishWithError(const QString &message, int httpStatus)
{
if (!m_currentReply.isNull())
QPointer<QNetworkReply> reply = m_currentReply;
clearReply();
if (!reply.isNull())
{
m_currentReply->abort();
reply->abort();
}
clearReply();
if (m_callback)
{
const ResponseCallback callback = std::move(m_callback);
@@ -197,6 +289,12 @@ void OpenAICompatibleProvider::finishWithError(const QString &message, int httpS
void OpenAICompatibleProvider::clearReply()
{
m_timeoutTimer.stop();
if (m_replyFinishedConnection)
{
QObject::disconnect(m_replyFinishedConnection);
m_replyFinishedConnection = {};
}
if (!m_currentReply.isNull())
{
m_currentReply->deleteLater();
+2
View File
@@ -4,6 +4,7 @@
#include "../config/AIConfig.h"
#include <QJsonObject>
#include <QMetaObject>
#include <QNetworkAccessManager>
#include <QPointer>
#include <QTimer>
@@ -31,6 +32,7 @@ private:
AIConfig m_config;
QNetworkAccessManager m_networkManager;
QPointer<QNetworkReply> m_currentReply;
QMetaObject::Connection m_replyFinishedConnection;
QTimer m_timeoutTimer;
ResponseCallback m_callback;
};
+119
View File
@@ -1,7 +1,9 @@
#include "PetWindow.h"
#include "../ai/OpenAICompatibleProvider.h"
#include "../character/CharacterPackageLoader.h"
#include "../config/ConfigManager.h"
#include "../config/SecretStore.h"
#include "../util/Logger.h"
#include "ChatBubble.h"
#include "PetView.h"
@@ -15,6 +17,7 @@
#include <QMenu>
#include <QMouseEvent>
#include <QPixmap>
#include <QPointer>
#include <QRandomGenerator>
#include <QScreen>
#include <QSet>
@@ -34,6 +37,40 @@ QString previewImagePath()
{
return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png");
}
bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
{
if (!config.apiKey.trimmed().isEmpty())
{
return true;
}
if (config.apiKeyStorage == QStringLiteral("windows-dpapi"))
{
if (config.apiKeyEncrypted.trimmed().isEmpty())
{
*errorMessage = QStringLiteral("请先在设置里配置 API Key。");
return false;
}
const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted);
if (!result.success)
{
*errorMessage = QStringLiteral("API Key 解密失败:") + result.errorMessage;
return false;
}
config.apiKey = result.value;
}
if (config.apiKey.trimmed().isEmpty())
{
*errorMessage = QStringLiteral("请先在设置里配置 API Key。");
return false;
}
return true;
}
}
PetWindow::PetWindow(QWidget *parent)
@@ -137,6 +174,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试"));
QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
addStateTestActions(&menu);
@@ -161,6 +199,10 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{
showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。"));
}
else if (selectedAction == aiTestAction)
{
startAITest();
}
else if (selectedAction == settingsAction)
{
ConfigManager configManager;
@@ -180,6 +222,83 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
}
}
void PetWindow::startAITest()
{
if (m_aiProvider && m_aiProvider->isBusy())
{
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
return;
}
ConfigManager configManager;
AIConfig config = configManager.loadAIConfig();
if (config.protocol != QStringLiteral("openai-compatible"))
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("当前 Provider 协议暂未接入 AI 测试。"));
return;
}
QString errorMessage;
if (!populateRuntimeApiKey(config, &errorMessage))
{
playState(QStringLiteral("error"), false);
showBubbleMessage(errorMessage);
return;
}
if (config.baseUrl.trimmed().isEmpty())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("请先在设置里配置 Base URL。"));
return;
}
if (config.model.trimmed().isEmpty())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("请先在设置里配置 Model。"));
return;
}
ChatRequest request;
request.messages.append({QStringLiteral("system"), QStringLiteral("你是桌宠的最小连通性测试助手。")});
request.messages.append({QStringLiteral("user"), QStringLiteral("请用一句话回复测试成功。")});
m_aiProvider = std::make_unique<OpenAICompatibleProvider>(config);
playState(QStringLiteral("think"), false);
showBubbleMessage(QStringLiteral("正在测试 AI 连接..."));
QPointer<PetWindow> window(this);
m_aiProvider->sendChatRequest(request, [window](const ChatResponse &response) {
if (window.isNull())
{
return;
}
if (response.success)
{
window->playState(QStringLiteral("talk"), false);
window->showBubbleMessage(response.content);
return;
}
QString message = response.errorMessage.trimmed();
if (message.isEmpty())
{
message = QStringLiteral("未知错误。");
}
if (response.httpStatus > 0)
{
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral("") + message;
}
window->playState(QStringLiteral("error"), false);
window->showBubbleMessage(QStringLiteral("AI 测试失败:") + message);
});
}
void PetWindow::mouseMoveEvent(QMouseEvent *event)
{
if (m_dragging && (event->buttons() & Qt::LeftButton))
+3
View File
@@ -17,6 +17,7 @@ class QMenu;
class QMoveEvent;
class QPixmap;
class ChatBubble;
class LLMProvider;
class PetView;
class PetWindow : public QWidget
@@ -42,6 +43,7 @@ private:
void loadInitialImage();
void buildAnimationClips();
void addStateTestActions(QMenu *menu);
void startAITest();
void updateBubblePosition();
QPoint bubbleAnchorPosition() const;
void playState(const QString &stateName, bool centerWindow);
@@ -55,6 +57,7 @@ private:
void setAlwaysOnTop(bool enabled);
std::unique_ptr<ChatBubble> m_chatBubble;
std::unique_ptr<LLMProvider> m_aiProvider;
PetView *m_petView;
QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer;