添加 AI 连通性测试入口
This commit is contained in:
@@ -6,10 +6,93 @@
|
|||||||
#include <QJsonParseError>
|
#include <QJsonParseError>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
|
#include <QStringList>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
#include <utility>
|
#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)
|
OpenAICompatibleProvider::OpenAICompatibleProvider(const AIConfig &config)
|
||||||
: m_config(config)
|
: m_config(config)
|
||||||
{
|
{
|
||||||
@@ -75,7 +158,7 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo
|
|||||||
|
|
||||||
const QJsonDocument document(buildPayload(request));
|
const QJsonDocument document(buildPayload(request));
|
||||||
m_currentReply = m_networkManager.post(networkRequest, document.toJson(QJsonDocument::Compact));
|
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())
|
if (m_currentReply.isNull())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -99,13 +182,14 @@ void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, Respo
|
|||||||
|
|
||||||
void OpenAICompatibleProvider::cancel()
|
void OpenAICompatibleProvider::cancel()
|
||||||
{
|
{
|
||||||
if (!m_currentReply.isNull())
|
|
||||||
{
|
|
||||||
m_currentReply->abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearReply();
|
|
||||||
m_callback = nullptr;
|
m_callback = nullptr;
|
||||||
|
QPointer<QNetworkReply> reply = m_currentReply;
|
||||||
|
clearReply();
|
||||||
|
|
||||||
|
if (!reply.isNull())
|
||||||
|
{
|
||||||
|
reply->abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request) const
|
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();
|
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
if (reply->error() != QNetworkReply::NoError)
|
if (reply->error() != QNetworkReply::NoError)
|
||||||
{
|
{
|
||||||
|
const QString bodyError = errorMessageFromBody(body);
|
||||||
|
if (!bodyError.isEmpty())
|
||||||
|
{
|
||||||
|
return {false, {}, bodyError, httpStatus};
|
||||||
|
}
|
||||||
|
|
||||||
return {false, {}, reply->errorString(), httpStatus};
|
return {false, {}, reply->errorString(), httpStatus};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,12 +270,14 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
|
|||||||
|
|
||||||
void OpenAICompatibleProvider::finishWithError(const QString &message, int httpStatus)
|
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)
|
if (m_callback)
|
||||||
{
|
{
|
||||||
const ResponseCallback callback = std::move(m_callback);
|
const ResponseCallback callback = std::move(m_callback);
|
||||||
@@ -197,6 +289,12 @@ void OpenAICompatibleProvider::finishWithError(const QString &message, int httpS
|
|||||||
void OpenAICompatibleProvider::clearReply()
|
void OpenAICompatibleProvider::clearReply()
|
||||||
{
|
{
|
||||||
m_timeoutTimer.stop();
|
m_timeoutTimer.stop();
|
||||||
|
if (m_replyFinishedConnection)
|
||||||
|
{
|
||||||
|
QObject::disconnect(m_replyFinishedConnection);
|
||||||
|
m_replyFinishedConnection = {};
|
||||||
|
}
|
||||||
|
|
||||||
if (!m_currentReply.isNull())
|
if (!m_currentReply.isNull())
|
||||||
{
|
{
|
||||||
m_currentReply->deleteLater();
|
m_currentReply->deleteLater();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "../config/AIConfig.h"
|
#include "../config/AIConfig.h"
|
||||||
|
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QMetaObject>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
@@ -31,6 +32,7 @@ private:
|
|||||||
AIConfig m_config;
|
AIConfig m_config;
|
||||||
QNetworkAccessManager m_networkManager;
|
QNetworkAccessManager m_networkManager;
|
||||||
QPointer<QNetworkReply> m_currentReply;
|
QPointer<QNetworkReply> m_currentReply;
|
||||||
|
QMetaObject::Connection m_replyFinishedConnection;
|
||||||
QTimer m_timeoutTimer;
|
QTimer m_timeoutTimer;
|
||||||
ResponseCallback m_callback;
|
ResponseCallback m_callback;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#include "PetWindow.h"
|
#include "PetWindow.h"
|
||||||
|
|
||||||
|
#include "../ai/OpenAICompatibleProvider.h"
|
||||||
#include "../character/CharacterPackageLoader.h"
|
#include "../character/CharacterPackageLoader.h"
|
||||||
#include "../config/ConfigManager.h"
|
#include "../config/ConfigManager.h"
|
||||||
|
#include "../config/SecretStore.h"
|
||||||
#include "../util/Logger.h"
|
#include "../util/Logger.h"
|
||||||
#include "ChatBubble.h"
|
#include "ChatBubble.h"
|
||||||
#include "PetView.h"
|
#include "PetView.h"
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
|
#include <QPointer>
|
||||||
#include <QRandomGenerator>
|
#include <QRandomGenerator>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
@@ -34,6 +37,40 @@ QString previewImagePath()
|
|||||||
{
|
{
|
||||||
return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png");
|
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)
|
PetWindow::PetWindow(QWidget *parent)
|
||||||
@@ -137,6 +174,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
||||||
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
||||||
|
|
||||||
|
QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试"));
|
||||||
QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
|
QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
|
||||||
|
|
||||||
addStateTestActions(&menu);
|
addStateTestActions(&menu);
|
||||||
@@ -161,6 +199,10 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
{
|
{
|
||||||
showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。"));
|
showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。"));
|
||||||
}
|
}
|
||||||
|
else if (selectedAction == aiTestAction)
|
||||||
|
{
|
||||||
|
startAITest();
|
||||||
|
}
|
||||||
else if (selectedAction == settingsAction)
|
else if (selectedAction == settingsAction)
|
||||||
{
|
{
|
||||||
ConfigManager configManager;
|
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)
|
void PetWindow::mouseMoveEvent(QMouseEvent *event)
|
||||||
{
|
{
|
||||||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class QMenu;
|
|||||||
class QMoveEvent;
|
class QMoveEvent;
|
||||||
class QPixmap;
|
class QPixmap;
|
||||||
class ChatBubble;
|
class ChatBubble;
|
||||||
|
class LLMProvider;
|
||||||
class PetView;
|
class PetView;
|
||||||
|
|
||||||
class PetWindow : public QWidget
|
class PetWindow : public QWidget
|
||||||
@@ -42,6 +43,7 @@ private:
|
|||||||
void loadInitialImage();
|
void loadInitialImage();
|
||||||
void buildAnimationClips();
|
void buildAnimationClips();
|
||||||
void addStateTestActions(QMenu *menu);
|
void addStateTestActions(QMenu *menu);
|
||||||
|
void startAITest();
|
||||||
void updateBubblePosition();
|
void updateBubblePosition();
|
||||||
QPoint bubbleAnchorPosition() const;
|
QPoint bubbleAnchorPosition() const;
|
||||||
void playState(const QString &stateName, bool centerWindow);
|
void playState(const QString &stateName, bool centerWindow);
|
||||||
@@ -55,6 +57,7 @@ private:
|
|||||||
void setAlwaysOnTop(bool enabled);
|
void setAlwaysOnTop(bool enabled);
|
||||||
|
|
||||||
std::unique_ptr<ChatBubble> m_chatBubble;
|
std::unique_ptr<ChatBubble> m_chatBubble;
|
||||||
|
std::unique_ptr<LLMProvider> m_aiProvider;
|
||||||
PetView *m_petView;
|
PetView *m_petView;
|
||||||
QTimer m_idleBehaviorTimer;
|
QTimer m_idleBehaviorTimer;
|
||||||
QTimer m_behaviorReturnTimer;
|
QTimer m_behaviorReturnTimer;
|
||||||
|
|||||||
Reference in New Issue
Block a user