接入 Google Gemini Provider

This commit is contained in:
2026-05-30 01:55:13 +08:00
parent d2793cad9c
commit 603c408d01
10 changed files with 923 additions and 40 deletions
+2
View File
@@ -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
+4 -4
View File
@@ -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`
+6 -6
View File
@@ -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. 发布前素材授权确认与打包验证
```
+20 -18
View File
@@ -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. 是否需要把对话历史持久化保存,还是第一版只保留内存会话
```
+756
View File
@@ -0,0 +1,756 @@
#include "GoogleGeminiProvider.h"
#include "../util/Logger.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QUrlQuery>
#include <utility>
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("<redacted>") : 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("<empty>") : config.provider.trimmed())
.arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.protocol.trimmed())
.arg(config.model.trimmed().isEmpty() ? QStringLiteral("<empty>") : 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<QNetworkReply> 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<int>(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<int>(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<QNetworkReply> 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;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include "LLMProvider.h"
#include "../config/AIConfig.h"
#include <QByteArray>
#include <QJsonObject>
#include <QMetaObject>
#include <QNetworkAccessManager>
#include <QPointer>
#include <QTimer>
#include <QUrl>
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<QNetworkReply> 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;
};
-8
View File
@@ -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");
+41 -1
View File
@@ -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;
+28 -2
View File
@@ -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<LLMProvider> createProvider(const AIConfig &config)
{
if (config.protocol == QStringLiteral("google-generative-language"))
{
return std::make_unique<GoogleGeminiProvider>(config);
}
if (config.protocol == QStringLiteral("openai-compatible"))
{
return std::make_unique<OpenAICompatibleProvider>(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<OpenAICompatibleProvider>(config)))
std::unique_ptr<LLMProvider> 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;
+7 -1
View File
@@ -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<QPair<QString, QString>> 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));