From f4c7e4a08b8c539047d64fb39324cb78ca49c1dc Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Fri, 29 May 2026 10:38:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20AI=20=E8=BF=9E=E9=80=9A?= =?UTF-8?q?=E6=80=A7=E6=B5=8B=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/OpenAICompatibleProvider.cpp | 118 ++++++++++++++++++++++++--- src/ai/OpenAICompatibleProvider.h | 2 + src/ui/PetWindow.cpp | 119 ++++++++++++++++++++++++++++ src/ui/PetWindow.h | 3 + 4 files changed, 232 insertions(+), 10 deletions(-) diff --git a/src/ai/OpenAICompatibleProvider.cpp b/src/ai/OpenAICompatibleProvider.cpp index 5f38707..22d842c 100644 --- a/src/ai/OpenAICompatibleProvider.cpp +++ b/src/ai/OpenAICompatibleProvider.cpp @@ -6,10 +6,93 @@ #include #include #include +#include #include #include +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 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 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(); diff --git a/src/ai/OpenAICompatibleProvider.h b/src/ai/OpenAICompatibleProvider.h index 0aac389..daa0ada 100644 --- a/src/ai/OpenAICompatibleProvider.h +++ b/src/ai/OpenAICompatibleProvider.h @@ -4,6 +4,7 @@ #include "../config/AIConfig.h" #include +#include #include #include #include @@ -31,6 +32,7 @@ private: AIConfig m_config; QNetworkAccessManager m_networkManager; QPointer m_currentReply; + QMetaObject::Connection m_replyFinishedConnection; QTimer m_timeoutTimer; ResponseCallback m_callback; }; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 8a72ea5..5e0d367 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -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 #include #include +#include #include #include #include @@ -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(config); + playState(QStringLiteral("think"), false); + showBubbleMessage(QStringLiteral("正在测试 AI 连接...")); + + QPointer 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)) diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 6e34278..cfcc77f 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -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 m_chatBubble; + std::unique_ptr m_aiProvider; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer;