diff --git a/CMakeLists.txt b/CMakeLists.txt index 8392e51..731f7ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,10 +10,14 @@ set(CMAKE_AUTOMOC OFF) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(Qt6 REQUIRED COMPONENTS Widgets Network) qt_add_executable(QtDesktopPet main.cpp + src/ai/LLMProvider.h + src/ai/LLMTypes.h + src/ai/OpenAICompatibleProvider.h + src/ai/OpenAICompatibleProvider.cpp src/character/AnimationClip.h src/character/AnimationClip.cpp src/character/CharacterPackage.h @@ -52,6 +56,7 @@ target_compile_definitions(QtDesktopPet target_link_libraries(QtDesktopPet PRIVATE + Qt6::Network Qt6::Widgets ) diff --git a/src/ai/LLMProvider.h b/src/ai/LLMProvider.h new file mode 100644 index 0000000..d7e0349 --- /dev/null +++ b/src/ai/LLMProvider.h @@ -0,0 +1,17 @@ +#pragma once + +#include "LLMTypes.h" + +#include + +class LLMProvider +{ +public: + using ResponseCallback = std::function; + + virtual ~LLMProvider() = default; + + virtual bool isBusy() const = 0; + virtual void sendChatRequest(const ChatRequest &request, ResponseCallback callback) = 0; + virtual void cancel() = 0; +}; diff --git a/src/ai/LLMTypes.h b/src/ai/LLMTypes.h new file mode 100644 index 0000000..5493280 --- /dev/null +++ b/src/ai/LLMTypes.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +struct ChatMessage +{ + QString role; + QString content; +}; + +struct ChatRequest +{ + QVector messages; +}; + +struct ChatResponse +{ + bool success = false; + QString content; + QString errorMessage; + int httpStatus = 0; +}; diff --git a/src/ai/OpenAICompatibleProvider.cpp b/src/ai/OpenAICompatibleProvider.cpp new file mode 100644 index 0000000..5f38707 --- /dev/null +++ b/src/ai/OpenAICompatibleProvider.cpp @@ -0,0 +1,205 @@ +#include "OpenAICompatibleProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +OpenAICompatibleProvider::OpenAICompatibleProvider(const AIConfig &config) + : m_config(config) +{ + m_timeoutTimer.setSingleShot(true); + QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() { + finishWithError(QStringLiteral("AI request timed out.")); + }); +} + +OpenAICompatibleProvider::~OpenAICompatibleProvider() +{ + cancel(); +} + +bool OpenAICompatibleProvider::isBusy() const +{ + return !m_currentReply.isNull(); +} + +void OpenAICompatibleProvider::sendChatRequest(const ChatRequest &request, ResponseCallback callback) +{ + if (isBusy()) + { + if (callback) + { + callback({false, {}, QStringLiteral("AI request is already running."), 0}); + } + return; + } + + if (m_config.baseUrl.trimmed().isEmpty()) + { + if (callback) + { + callback({false, {}, QStringLiteral("Base URL is empty."), 0}); + } + return; + } + + if (m_config.model.trimmed().isEmpty()) + { + if (callback) + { + callback({false, {}, QStringLiteral("Model is empty."), 0}); + } + return; + } + + if (m_config.apiKey.trimmed().isEmpty()) + { + if (callback) + { + callback({false, {}, QStringLiteral("API Key is empty."), 0}); + } + return; + } + + m_callback = std::move(callback); + + QNetworkRequest networkRequest(requestUrl()); + 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)); + QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() { + if (m_currentReply.isNull()) + { + return; + } + + QNetworkReply *reply = m_currentReply; + const QByteArray body = reply->readAll(); + ChatResponse response = parseResponse(reply, body); + clearReply(); + + if (m_callback) + { + const ResponseCallback callback = std::move(m_callback); + m_callback = nullptr; + callback(response); + } + }); + + m_timeoutTimer.start(m_config.timeoutMs); +} + +void OpenAICompatibleProvider::cancel() +{ + if (!m_currentReply.isNull()) + { + m_currentReply->abort(); + } + + clearReply(); + m_callback = nullptr; +} + +QJsonObject OpenAICompatibleProvider::buildPayload(const ChatRequest &request) const +{ + QJsonArray messages; + for (const ChatMessage &message : request.messages) + { + QJsonObject item; + item.insert(QStringLiteral("role"), message.role); + item.insert(QStringLiteral("content"), message.content); + messages.append(item); + } + + QJsonObject payload; + payload.insert(QStringLiteral("model"), m_config.model); + payload.insert(QStringLiteral("messages"), messages); + payload.insert(QStringLiteral("stream"), false); + payload.insert(QStringLiteral("temperature"), m_config.temperature); + payload.insert(QStringLiteral("max_tokens"), m_config.maxTokens); + return payload; +} + +QUrl OpenAICompatibleProvider::requestUrl() const +{ + QString baseUrl = m_config.baseUrl.trimmed(); + QString path = m_config.path.trimmed(); + + while (baseUrl.endsWith(QLatin1Char('/'))) + { + baseUrl.chop(1); + } + + if (!path.startsWith(QLatin1Char('/'))) + { + path.prepend(QLatin1Char('/')); + } + + return QUrl(baseUrl + path); +} + +ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const QByteArray &body) const +{ + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) + { + return {false, {}, reply->errorString(), httpStatus}; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus}; + } + + const QJsonObject root = document.object(); + const QJsonArray choices = root.value(QStringLiteral("choices")).toArray(); + if (choices.isEmpty() || !choices.first().isObject()) + { + return {false, {}, QStringLiteral("AI response has no choices."), httpStatus}; + } + + const QJsonObject message = choices.first().toObject().value(QStringLiteral("message")).toObject(); + const QString content = message.value(QStringLiteral("content")).toString(); + if (content.isEmpty()) + { + return {false, {}, QStringLiteral("AI response content is empty."), httpStatus}; + } + + return {true, content, {}, httpStatus}; +} + +void OpenAICompatibleProvider::finishWithError(const QString &message, int httpStatus) +{ + if (!m_currentReply.isNull()) + { + m_currentReply->abort(); + } + + clearReply(); + if (m_callback) + { + const ResponseCallback callback = std::move(m_callback); + m_callback = nullptr; + callback({false, {}, message, httpStatus}); + } +} + +void OpenAICompatibleProvider::clearReply() +{ + m_timeoutTimer.stop(); + if (!m_currentReply.isNull()) + { + m_currentReply->deleteLater(); + m_currentReply.clear(); + } +} diff --git a/src/ai/OpenAICompatibleProvider.h b/src/ai/OpenAICompatibleProvider.h new file mode 100644 index 0000000..0aac389 --- /dev/null +++ b/src/ai/OpenAICompatibleProvider.h @@ -0,0 +1,36 @@ +#pragma once + +#include "LLMProvider.h" +#include "../config/AIConfig.h" + +#include +#include +#include +#include +#include + +class QNetworkReply; + +class OpenAICompatibleProvider : public LLMProvider +{ +public: + explicit OpenAICompatibleProvider(const AIConfig &config); + ~OpenAICompatibleProvider() override; + + bool isBusy() const override; + void sendChatRequest(const ChatRequest &request, ResponseCallback callback) override; + void cancel() override; + +private: + QJsonObject buildPayload(const ChatRequest &request) const; + QUrl requestUrl() const; + ChatResponse parseResponse(QNetworkReply *reply, const QByteArray &body) const; + void finishWithError(const QString &message, int httpStatus = 0); + void clearReply(); + + AIConfig m_config; + QNetworkAccessManager m_networkManager; + QPointer m_currentReply; + QTimer m_timeoutTimer; + ResponseCallback m_callback; +};