From 603c408d01ee379c0ff11861b196eef76d67b475 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Sat, 30 May 2026 01:55:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Google=20Gemini=20Provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 2 + README.md | 8 +- docs/Qt_DesktopPet_开发文档.md | 12 +- docs/implementation_plan.md | 38 +- src/ai/GoogleGeminiProvider.cpp | 756 ++++++++++++++++++++++++++++++++ src/ai/GoogleGeminiProvider.h | 59 +++ src/config/AIConfig.cpp | 8 - src/config/ConfigManager.cpp | 42 +- src/ui/PetWindow.cpp | 30 +- src/ui/SettingsDialog.cpp | 8 +- 10 files changed, 923 insertions(+), 40 deletions(-) create mode 100644 src/ai/GoogleGeminiProvider.cpp create mode 100644 src/ai/GoogleGeminiProvider.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f2ebc2..7622910 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,8 @@ qt_add_executable(QtDesktopPet src/ai/ConversationManager.cpp src/ai/LLMProvider.h src/ai/LLMTypes.h + src/ai/GoogleGeminiProvider.h + src/ai/GoogleGeminiProvider.cpp src/ai/OpenAICompatibleProvider.h src/ai/OpenAICompatibleProvider.cpp src/character/AnimationClip.h diff --git a/README.md b/README.md index c436b63..d7f4693 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型 - AI 回复气泡 - 对话历史面板 - AI 请求取消和对话清空 +- Google Gemini 原生聊天请求 尚未实现: -- Google / Claude 原生协议 Provider - 设置页内 AI 连通性测试 - 缩放和性能模式 UI - AppConfig 中缩放 / 性能字段的实际应用 @@ -132,15 +132,14 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log ## AI 配置和聊天 -当前正式聊天使用 OpenAI Compatible 协议。已提供以下 Provider 配置入口: +当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口: - OpenAI - Google -- Claude - DeepSeek - Custom -其中 OpenAI、DeepSeek、Custom 走 OpenAI Compatible 形式配置。Google 和 Claude 当前主要是配置预留,正式聊天运行时还未接入对应原生协议。 +其中 OpenAI、DeepSeek、Custom 走 OpenAI Compatible 形式配置;Google 走 Gemini 原生 REST 接口。旧版保存过的已废弃 Provider 配置会在读取 AI 配置时清理,废弃 Provider 被选中时会回退为 `custom`。 已支持: @@ -150,6 +149,7 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log - 用户自定义 Path - 超时、Temperature、Max Tokens - 流式输出 +- Google Gemini `generateContent` / `streamGenerateContent` - 请求中切换 `think` - 收到首段输出后切换并保持 `talk` - 失败时切换 `error` diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/Qt_DesktopPet_开发文档.md index a6d19c9..5fce7dc 100644 --- a/docs/Qt_DesktopPet_开发文档.md +++ b/docs/Qt_DesktopPet_开发文档.md @@ -1663,6 +1663,7 @@ Windows 优先 PNG 多状态帧动画角色包 轻量状态机 OpenAI Compatible 自定义 AI 接口 +Google Gemini 原生 AI 接口 AI 对话与桌宠动画联动 SSE 流式输出 聊天输入框、回复气泡和对话历史面板 @@ -1682,10 +1683,9 @@ MIT License 开源 ```text 1. 设置页内 AI 连通性测试 -2. Google / Claude 原生协议 Provider,或在 UI 中明确提示暂未接入 -3. 对话历史内存上限和可选持久化 -4. AppConfig 中缩放、性能模式等字段的实际应用 -5. character.json 中 base、anchor、bubble offset 的解析与应用 -6. 角色包位置整理、角色切换和懒加载策略 -7. 发布前素材授权确认与打包验证 +2. 对话历史内存上限和可选持久化 +3. AppConfig 中缩放、性能模式等字段的实际应用 +4. character.json 中 base、anchor、bubble offset 的解析与应用 +5. 角色包位置整理、角色切换和懒加载策略 +6. 发布前素材授权确认与打包验证 ``` diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index 16381b3..8750f78 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -553,8 +553,9 @@ error :20 帧 当前尚未形成自动化性能测试或长期压测记录 8. 阶段 6 AI 接入: - 已新增 LLMProvider / OpenAICompatibleProvider / ConversationManager + 已新增 LLMProvider / OpenAICompatibleProvider / GoogleGeminiProvider / ConversationManager 已支持 OpenAI Compatible 异步请求、超时、取消、错误提示和网络诊断日志 + 已支持 Google Gemini generateContent / streamGenerateContent、x-goog-api-key、contents 多轮上下文和 systemInstruction 已支持 SSE 流式输出,气泡中流式显示,历史面板只记录最终对话 已限制同一时间只允许一个 AI 请求 已避免在日志中输出完整 API Key 和完整消息正文 @@ -563,7 +564,8 @@ error :20 帧 已新增 ChatBubble、ChatInputDialog、ChatHistoryPanel、SettingsDialog 已支持右键聊天、显示对话、取消 AI 请求、清空对话、设置 已删除临时 AI 测试入口和气泡测试入口 - 已支持 OpenAI / Google / Claude / DeepSeek / Custom 配置分 Provider 保存 + 已支持 OpenAI / Google / DeepSeek / Custom 配置分 Provider 保存 + 已移除废弃 Provider 配置入口,并在读取旧配置时清理废弃 Provider 配置 Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存 ``` @@ -571,14 +573,12 @@ error :20 帧 ```text 1. shiroko 角色包仍位于项目根目录 shiroko/,尚未移动到 resources/characters/shiroko -2. Google / Claude 目前只有配置入口,正式聊天运行时仍只接入 openai-compatible 协议 -3. SettingsDialog 仍是最小设置界面,尚未包含 AI 测试按钮、应用设置、角色选择、缩放和性能模式 UI -4. ConfigManager 已有缩放和性能字段,但 PetWindow 尚未真正应用缩放、性能模式和角色选择 -5. CharacterPackage 尚未解析并应用 character.json 中的 base、anchor、bubble offset -6. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪 -7. 当前 FrameAnimator 采用当前角色包全部状态帧预加载,尚未做懒加载 -8. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 -9. 最近一次流式状态修正已本地提交,推送时遇到远程认证失败,需要重新认证后推送 +2. SettingsDialog 仍是最小设置界面,尚未包含 AI 测试按钮、应用设置、角色选择、缩放和性能模式 UI +3. ConfigManager 已有缩放和性能字段,但 PetWindow 尚未真正应用缩放、性能模式和角色选择 +4. CharacterPackage 尚未解析并应用 character.json 中的 base、anchor、bubble offset +5. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪 +6. 当前 FrameAnimator 采用当前角色包全部状态帧预加载,尚未做懒加载 +7. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 ``` --- @@ -588,7 +588,11 @@ error :20 帧 短期建议: ```text -1. 解决远程仓库认证问题,并推送本地提交 +1. 用户手测 Google Gemini Provider: + - Google Provider 配置保存 + - Gemini 普通回复 + - Gemini 流式回复 + - 错误 Key / 错误模型错误提示 2. 用户手测流式状态修正: - 发送消息后等待阶段应保持 think - 等待阶段拖动松开应回到 think @@ -623,11 +627,9 @@ error :20 帧 后续开始写代码前,需要逐项确认: ```text -1. 远程仓库认证失败,当前本地提交尚未推送成功 -2. 是否把 shiroko 移动到 resources/characters/shiroko -3. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载 -4. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 -5. Google / Claude 是先禁用正式聊天提示,还是继续实现原生 Provider -6. 设置页下一步先做 AI 测试入口,还是先做应用缩放 / 性能设置 -7. 是否需要把对话历史持久化保存,还是第一版只保留内存会话 +1. 是否把 shiroko 移动到 resources/characters/shiroko +2. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载 +3. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 +4. 设置页下一步先做 AI 测试入口,还是先做应用缩放 / 性能设置 +5. 是否需要把对话历史持久化保存,还是第一版只保留内存会话 ``` diff --git a/src/ai/GoogleGeminiProvider.cpp b/src/ai/GoogleGeminiProvider.cpp new file mode 100644 index 0000000..2142ba8 --- /dev/null +++ b/src/ai/GoogleGeminiProvider.cpp @@ -0,0 +1,756 @@ +#include "GoogleGeminiProvider.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +constexpr int MaxDiagnosticBodyLength = 1000; + +QString trimmedResponseBody(const QByteArray &body) +{ + const QString text = QString::fromUtf8(body).trimmed(); + if (text.size() <= MaxDiagnosticBodyLength) + { + return text; + } + + 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) +{ + 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 QJsonValue codeValue = error.value(QStringLiteral("code")); + const QString code = codeValue.isString() + ? codeValue.toString().trimmed() + : (codeValue.isDouble() ? QString::number(codeValue.toInt()) : QString()); + const QString status = error.value(QStringLiteral("status")).toString().trimmed(); + + if (!message.isEmpty()) + { + details.append(message); + } + if (!code.isEmpty()) + { + details.append(QStringLiteral("code=") + code); + } + if (!status.isEmpty() && status != code) + { + details.append(QStringLiteral("status=") + status); + } + } + 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); + } + + if (!details.isEmpty()) + { + return details.join(QStringLiteral("; ")); + } + + return trimmedResponseBody(body); +} + +QString normalizedGeminiModel(QString model) +{ + model = model.trimmed(); + if (model.startsWith(QStringLiteral("models/"))) + { + model.remove(0, QStringLiteral("models/").size()); + } + + return model; +} + +QString geminiRole(const QString &role) +{ + if (role == QStringLiteral("assistant") || role == QStringLiteral("model")) + { + return QStringLiteral("model"); + } + + return QStringLiteral("user"); +} + +QJsonObject textPart(const QString &text) +{ + QJsonObject part; + part.insert(QStringLiteral("text"), text); + return part; +} + +QString textFromGeminiCandidate(const QJsonObject &candidate) +{ + const QJsonObject content = candidate.value(QStringLiteral("content")).toObject(); + const QJsonArray parts = content.value(QStringLiteral("parts")).toArray(); + + QString result; + for (const QJsonValue &partValue : parts) + { + if (!partValue.isObject()) + { + continue; + } + + const QString text = partValue.toObject().value(QStringLiteral("text")).toString(); + if (!text.isEmpty()) + { + result += text; + } + } + + return result; +} + +QString textFromGeminiResponse(const QJsonObject &root) +{ + const QJsonArray candidates = root.value(QStringLiteral("candidates")).toArray(); + if (candidates.isEmpty() || !candidates.first().isObject()) + { + return {}; + } + + return textFromGeminiCandidate(candidates.first().toObject()); +} + +QString geminiEmptyResponseReason(const QJsonObject &root) +{ + QStringList details; + + const QJsonObject promptFeedback = root.value(QStringLiteral("promptFeedback")).toObject(); + const QString blockReason = promptFeedback.value(QStringLiteral("blockReason")).toString().trimmed(); + if (!blockReason.isEmpty()) + { + details.append(QStringLiteral("prompt blocked: ") + blockReason); + } + + const QJsonArray candidates = root.value(QStringLiteral("candidates")).toArray(); + if (!candidates.isEmpty() && candidates.first().isObject()) + { + const QJsonObject candidate = candidates.first().toObject(); + const QString finishReason = candidate.value(QStringLiteral("finishReason")).toString().trimmed(); + if (!finishReason.isEmpty()) + { + details.append(QStringLiteral("finishReason=") + finishReason); + } + } + + if (!details.isEmpty()) + { + return details.join(QStringLiteral("; ")); + } + + return QStringLiteral("Gemini response content is empty."); +} +} + +GoogleGeminiProvider::GoogleGeminiProvider(const AIConfig &config) + : m_config(config) +{ + m_timeoutTimer.setSingleShot(true); + QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() { + finishWithError(QStringLiteral("AI request timed out.")); + }); +} + +GoogleGeminiProvider::~GoogleGeminiProvider() +{ + cancel(); +} + +bool GoogleGeminiProvider::isBusy() const +{ + return !m_currentReply.isNull(); +} + +void GoogleGeminiProvider::sendChatRequest(const ChatRequest &request, ResponseCallback callback) +{ + sendChatRequestInternal(request, false, nullptr, std::move(callback)); +} + +void GoogleGeminiProvider::sendStreamingChatRequest( + const ChatRequest &request, + StreamCallback streamCallback, + ResponseCallback callback) +{ + sendChatRequestInternal(request, true, std::move(streamCallback), std::move(callback)); +} + +void GoogleGeminiProvider::sendChatRequestInternal( + const ChatRequest &request, + bool stream, + StreamCallback streamCallback, + 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); + m_streamCallback = std::move(streamCallback); + m_streaming = stream; + m_streamDone = false; + m_streamBuffer.clear(); + m_streamRawBody.clear(); + m_streamedContent.clear(); + + const QUrl url = requestUrl(stream); + QNetworkRequest networkRequest(url); + networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + networkRequest.setRawHeader("x-goog-api-key", m_config.apiKey.toUtf8()); + if (stream) + { + networkRequest.setRawHeader("Accept", "text/event-stream"); + } + + const QJsonDocument document(buildPayload(request)); + 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()) + { + return; + } + + QNetworkReply *reply = m_currentReply; + const QByteArray body = reply->readAll(); + 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) + { + const ResponseCallback callback = std::move(m_callback); + m_callback = nullptr; + callback(response); + } + }); + + m_timeoutTimer.start(m_config.timeoutMs); +} + +void GoogleGeminiProvider::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()) + { + reply->abort(); + } +} + +QJsonObject GoogleGeminiProvider::buildPayload(const ChatRequest &request) const +{ + QStringList systemParts; + QJsonArray contents; + + for (const ChatMessage &message : request.messages) + { + const QString content = message.content.trimmed(); + if (content.isEmpty()) + { + continue; + } + + if (message.role == QStringLiteral("system")) + { + systemParts.append(content); + continue; + } + + QJsonArray parts; + parts.append(textPart(content)); + + QJsonObject item; + item.insert(QStringLiteral("role"), geminiRole(message.role)); + item.insert(QStringLiteral("parts"), parts); + contents.append(item); + } + + QJsonObject payload; + payload.insert(QStringLiteral("contents"), contents); + + if (!systemParts.isEmpty()) + { + QJsonArray parts; + parts.append(textPart(systemParts.join(QStringLiteral("\n\n")))); + + QJsonObject systemInstruction; + systemInstruction.insert(QStringLiteral("parts"), parts); + payload.insert(QStringLiteral("systemInstruction"), systemInstruction); + } + + QJsonObject generationConfig; + generationConfig.insert(QStringLiteral("temperature"), m_config.temperature); + generationConfig.insert(QStringLiteral("maxOutputTokens"), m_config.maxTokens); + payload.insert(QStringLiteral("generationConfig"), generationConfig); + + return payload; +} + +QUrl GoogleGeminiProvider::requestUrl(bool stream) const +{ + QString baseUrl = m_config.baseUrl.trimmed(); + QString path = m_config.path.trimmed(); + if (path.isEmpty()) + { + path = QStringLiteral("/v1beta/models/{model}:generateContent"); + } + + while (baseUrl.endsWith(QLatin1Char('/'))) + { + baseUrl.chop(1); + } + + if (!path.startsWith(QLatin1Char('/'))) + { + path.prepend(QLatin1Char('/')); + } + + if (stream && path.contains(QStringLiteral(":generateContent"))) + { + path.replace(QStringLiteral(":generateContent"), QStringLiteral(":streamGenerateContent")); + } + else if (!stream && path.contains(QStringLiteral(":streamGenerateContent"))) + { + path.replace(QStringLiteral(":streamGenerateContent"), QStringLiteral(":generateContent")); + } + + const QString model = QString::fromUtf8(QUrl::toPercentEncoding(normalizedGeminiModel(m_config.model))); + path.replace(QStringLiteral("{model}"), model); + + QUrl url(baseUrl + path); + if (stream) + { + QUrlQuery query(url); + if (!query.hasQueryItem(QStringLiteral("alt"))) + { + query.addQueryItem(QStringLiteral("alt"), QStringLiteral("sse")); + } + url.setQuery(query); + } + + return url; +} + +ChatResponse GoogleGeminiProvider::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}; + } + + return {false, {}, reply->errorString(), httpStatus}; + } + + QJsonParseError parseError; + 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}; + } + + const QJsonObject root = document.object(); + if (root.contains(QStringLiteral("error"))) + { + const QString bodyError = errorMessageFromBody(body); + Logger::warning(QStringLiteral("AI response returned error: %1 httpStatus=%2 body=\"%3\"") + .arg(diagnosticContext(m_config, reply->request().url())) + .arg(httpStatus) + .arg(oneLine(trimmedResponseBody(body)))); + return {false, {}, bodyError.isEmpty() ? QStringLiteral("AI response returned an error.") : bodyError, httpStatus}; + } + + const QString content = textFromGeminiResponse(root); + if (content.isEmpty()) + { + const QString reason = geminiEmptyResponseReason(root); + Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 reason=\"%3\" body=\"%4\"") + .arg(diagnosticContext(m_config, reply->request().url())) + .arg(httpStatus) + .arg(oneLine(reason)) + .arg(oneLine(trimmedResponseBody(body)))); + return {false, {}, reason, 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}; +} + +void GoogleGeminiProvider::handleStreamReadyRead() +{ + if (m_currentReply.isNull()) + { + return; + } + + const QByteArray chunk = m_currentReply->readAll(); + m_streamRawBody.append(chunk); + m_streamBuffer.append(chunk); + processStreamBuffer(); +} + +void GoogleGeminiProvider::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(':') || line.startsWith("event:")) + { + continue; + } + + if (!line.startsWith("data:")) + { + continue; + } + + const QByteArray payload = line.mid(5).trimmed(); + if (!handleStreamPayload(payload)) + { + return; + } + } +} + +bool GoogleGeminiProvider::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(true))) + .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 QString content = textFromGeminiResponse(root); + if (content.isEmpty()) + { + return true; + } + + m_streamedContent += content; + if (m_streamCallback) + { + m_streamCallback(content); + } + + return true; +} + +ChatResponse GoogleGeminiProvider::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("Gemini 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 GoogleGeminiProvider::finishWithError(const QString &message, int httpStatus) +{ + QPointer reply = m_currentReply; + const QUrl url = reply.isNull() ? requestUrl(m_streaming) : 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()) + { + reply->abort(); + } + + if (m_callback) + { + const ResponseCallback callback = std::move(m_callback); + m_callback = nullptr; + callback({false, {}, message, httpStatus}); + } +} + +void GoogleGeminiProvider::clearReply() +{ + m_timeoutTimer.stop(); + if (m_replyFinishedConnection) + { + 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/GoogleGeminiProvider.h b/src/ai/GoogleGeminiProvider.h new file mode 100644 index 0000000..b7480af --- /dev/null +++ b/src/ai/GoogleGeminiProvider.h @@ -0,0 +1,59 @@ +#pragma once + +#include "LLMProvider.h" +#include "../config/AIConfig.h" + +#include +#include +#include +#include +#include +#include +#include + +class QNetworkReply; + +class GoogleGeminiProvider : public LLMProvider +{ +public: + explicit GoogleGeminiProvider(const AIConfig &config); + ~GoogleGeminiProvider() override; + + 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; + QUrl requestUrl(bool stream) 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(); + + AIConfig m_config; + 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/config/AIConfig.cpp b/src/config/AIConfig.cpp index d46eceb..7df723e 100644 --- a/src/config/AIConfig.cpp +++ b/src/config/AIConfig.cpp @@ -22,14 +22,6 @@ AIConfig defaultAIConfigForProvider(const QString &provider) return config; } - if (normalizedProvider == QStringLiteral("claude")) - { - config.protocol = QStringLiteral("anthropic"); - config.baseUrl = QStringLiteral("https://api.anthropic.com"); - config.path = QStringLiteral("/v1/messages"); - return config; - } - if (normalizedProvider == QStringLiteral("google")) { config.protocol = QStringLiteral("google-generative-language"); diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index c9c300e..29b0b47 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -41,6 +41,13 @@ QString normalizedProviderName(const QString &provider) return normalized.isEmpty() ? QStringLiteral("custom") : normalized; } +bool isRemovedProviderName(const QString &provider) +{ + const QString normalized = provider.trimmed().toLower(); + return normalized == QStringLiteral("claude") + || normalized == QStringLiteral("calude"); +} + QJsonObject objectFromAIProviderConfig(const AIConfig &config) { QJsonObject root; @@ -69,13 +76,21 @@ QJsonObject objectFromAIConfigStore(const AIConfigStore &store) for (auto iterator = store.providers.constBegin(); iterator != store.providers.constEnd(); ++iterator) { const QString provider = normalizedProviderName(iterator.key()); + if (isRemovedProviderName(provider)) + { + continue; + } + AIConfig config = iterator.value(); config.provider = provider; providers.insert(provider, objectFromAIProviderConfig(config)); } QJsonObject root; - root.insert(QStringLiteral("activeProvider"), normalizedProviderName(store.activeProvider)); + const QString activeProvider = normalizedProviderName(store.activeProvider); + root.insert( + QStringLiteral("activeProvider"), + isRemovedProviderName(activeProvider) ? QStringLiteral("custom") : activeProvider); root.insert(QStringLiteral("providers"), providers); return root; } @@ -210,6 +225,12 @@ AIConfigStore ConfigManager::loadAIConfigStore() const const QJsonObject root = document.object(); store.activeProvider = normalizedProviderName(root.value(QStringLiteral("activeProvider")).toString(store.activeProvider)); + bool removedLegacyProviderConfig = false; + if (isRemovedProviderName(store.activeProvider)) + { + store.activeProvider = QStringLiteral("custom"); + removedLegacyProviderConfig = true; + } const QJsonObject providers = root.value(QStringLiteral("providers")).toObject(); for (auto iterator = providers.constBegin(); iterator != providers.constEnd(); ++iterator) @@ -220,9 +241,24 @@ AIConfigStore ConfigManager::loadAIConfigStore() const } const QString provider = normalizedProviderName(iterator.key()); + const QString declaredProvider = normalizedProviderName( + iterator.value().toObject().value(QStringLiteral("provider")).toString(provider)); + if (isRemovedProviderName(provider) || isRemovedProviderName(declaredProvider)) + { + removedLegacyProviderConfig = true; + continue; + } + store.providers.insert(provider, aiProviderConfigFromObject(provider, iterator.value().toObject())); } + if (removedLegacyProviderConfig) + { + file.close(); + Logger::info(QStringLiteral("Removed legacy AI provider config.")); + saveAIConfigStore(store); + } + return store; } @@ -281,6 +317,10 @@ bool ConfigManager::saveAIConfig(const AIConfig &config) const { AIConfig normalizedConfig = config; normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider); + if (isRemovedProviderName(normalizedConfig.provider)) + { + normalizedConfig = defaultAIConfigForProvider(QStringLiteral("custom")); + } AIConfigStore store = loadAIConfigStore(); store.activeProvider = normalizedConfig.provider; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index b705c10..0fb5d62 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -1,6 +1,7 @@ #include "PetWindow.h" #include "../ai/ConversationManager.h" +#include "../ai/GoogleGeminiProvider.h" #include "../ai/OpenAICompatibleProvider.h" #include "../character/CharacterPackageLoader.h" #include "../config/ConfigManager.h" @@ -82,7 +83,9 @@ bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage) bool prepareRuntimeAIConfig(AIConfig &config, QString *errorMessage) { - if (config.protocol != QStringLiteral("openai-compatible")) + const bool supportedProtocol = config.protocol == QStringLiteral("openai-compatible") + || config.protocol == QStringLiteral("google-generative-language"); + if (!supportedProtocol) { *errorMessage = QStringLiteral("当前 Provider 协议暂未接入。"); return false; @@ -108,6 +111,21 @@ bool prepareRuntimeAIConfig(AIConfig &config, QString *errorMessage) return true; } +std::unique_ptr createProvider(const AIConfig &config) +{ + if (config.protocol == QStringLiteral("google-generative-language")) + { + return std::make_unique(config); + } + + if (config.protocol == QStringLiteral("openai-compatible")) + { + return std::make_unique(config); + } + + return nullptr; +} + QString userVisibleErrorMessage(const ChatResponse &response) { QString message = response.errorMessage.trimmed(); @@ -333,7 +351,15 @@ bool PetWindow::submitChatMessage(const QString &message) return false; } - if (!m_conversationManager->setProvider(std::make_unique(config))) + std::unique_ptr provider = createProvider(config); + if (!provider) + { + playState(QStringLiteral("error"), false); + showBubbleMessage(QStringLiteral("当前 Provider 协议暂未接入。")); + return false; + } + + if (!m_conversationManager->setProvider(std::move(provider))) { showBubbleMessage(QStringLiteral("AI 回复正在进行。")); return false; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 84fc61b..d8b2fe2 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -19,6 +19,11 @@ namespace QString normalizedProviderName(const QString &provider) { const QString normalized = provider.trimmed().toLower(); + if (normalized == QStringLiteral("claude") || normalized == QStringLiteral("calude")) + { + return QStringLiteral("custom"); + } + return normalized.isEmpty() ? QStringLiteral("custom") : normalized; } } @@ -43,7 +48,6 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent const QList> providers = { {QStringLiteral("openai"), QStringLiteral("OpenAI")}, {QStringLiteral("google"), QStringLiteral("Google")}, - {QStringLiteral("claude"), QStringLiteral("Claude")}, {QStringLiteral("deepseek"), QStringLiteral("DeepSeek")}, {QStringLiteral("custom"), QStringLiteral("Custom")}, }; @@ -106,6 +110,8 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent AIConfigStore SettingsDialog::aiConfigStore() const { AIConfigStore store = m_configStore; + store.providers.remove(QStringLiteral("claude")); + store.providers.remove(QStringLiteral("calude")); const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString()); store.activeProvider = provider; store.providers.insert(provider, configFromForm(provider));