接入 Google Gemini Provider
This commit is contained in:
@@ -18,6 +18,8 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/ai/ConversationManager.cpp
|
src/ai/ConversationManager.cpp
|
||||||
src/ai/LLMProvider.h
|
src/ai/LLMProvider.h
|
||||||
src/ai/LLMTypes.h
|
src/ai/LLMTypes.h
|
||||||
|
src/ai/GoogleGeminiProvider.h
|
||||||
|
src/ai/GoogleGeminiProvider.cpp
|
||||||
src/ai/OpenAICompatibleProvider.h
|
src/ai/OpenAICompatibleProvider.h
|
||||||
src/ai/OpenAICompatibleProvider.cpp
|
src/ai/OpenAICompatibleProvider.cpp
|
||||||
src/character/AnimationClip.h
|
src/character/AnimationClip.h
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型
|
|||||||
- AI 回复气泡
|
- AI 回复气泡
|
||||||
- 对话历史面板
|
- 对话历史面板
|
||||||
- AI 请求取消和对话清空
|
- AI 请求取消和对话清空
|
||||||
|
- Google Gemini 原生聊天请求
|
||||||
|
|
||||||
尚未实现:
|
尚未实现:
|
||||||
|
|
||||||
- Google / Claude 原生协议 Provider
|
|
||||||
- 设置页内 AI 连通性测试
|
- 设置页内 AI 连通性测试
|
||||||
- 缩放和性能模式 UI
|
- 缩放和性能模式 UI
|
||||||
- AppConfig 中缩放 / 性能字段的实际应用
|
- AppConfig 中缩放 / 性能字段的实际应用
|
||||||
@@ -132,15 +132,14 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
|
|||||||
|
|
||||||
## AI 配置和聊天
|
## AI 配置和聊天
|
||||||
|
|
||||||
当前正式聊天使用 OpenAI Compatible 协议。已提供以下 Provider 配置入口:
|
当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口:
|
||||||
|
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Google
|
- Google
|
||||||
- Claude
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- Custom
|
- 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
|
- 用户自定义 Path
|
||||||
- 超时、Temperature、Max Tokens
|
- 超时、Temperature、Max Tokens
|
||||||
- 流式输出
|
- 流式输出
|
||||||
|
- Google Gemini `generateContent` / `streamGenerateContent`
|
||||||
- 请求中切换 `think`
|
- 请求中切换 `think`
|
||||||
- 收到首段输出后切换并保持 `talk`
|
- 收到首段输出后切换并保持 `talk`
|
||||||
- 失败时切换 `error`
|
- 失败时切换 `error`
|
||||||
|
|||||||
@@ -1663,6 +1663,7 @@ Windows 优先
|
|||||||
PNG 多状态帧动画角色包
|
PNG 多状态帧动画角色包
|
||||||
轻量状态机
|
轻量状态机
|
||||||
OpenAI Compatible 自定义 AI 接口
|
OpenAI Compatible 自定义 AI 接口
|
||||||
|
Google Gemini 原生 AI 接口
|
||||||
AI 对话与桌宠动画联动
|
AI 对话与桌宠动画联动
|
||||||
SSE 流式输出
|
SSE 流式输出
|
||||||
聊天输入框、回复气泡和对话历史面板
|
聊天输入框、回复气泡和对话历史面板
|
||||||
@@ -1682,10 +1683,9 @@ MIT License 开源
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. 设置页内 AI 连通性测试
|
1. 设置页内 AI 连通性测试
|
||||||
2. Google / Claude 原生协议 Provider,或在 UI 中明确提示暂未接入
|
2. 对话历史内存上限和可选持久化
|
||||||
3. 对话历史内存上限和可选持久化
|
3. AppConfig 中缩放、性能模式等字段的实际应用
|
||||||
4. AppConfig 中缩放、性能模式等字段的实际应用
|
4. character.json 中 base、anchor、bubble offset 的解析与应用
|
||||||
5. character.json 中 base、anchor、bubble offset 的解析与应用
|
5. 角色包位置整理、角色切换和懒加载策略
|
||||||
6. 角色包位置整理、角色切换和懒加载策略
|
6. 发布前素材授权确认与打包验证
|
||||||
7. 发布前素材授权确认与打包验证
|
|
||||||
```
|
```
|
||||||
|
|||||||
+20
-18
@@ -553,8 +553,9 @@ error :20 帧
|
|||||||
当前尚未形成自动化性能测试或长期压测记录
|
当前尚未形成自动化性能测试或长期压测记录
|
||||||
|
|
||||||
8. 阶段 6 AI 接入:
|
8. 阶段 6 AI 接入:
|
||||||
已新增 LLMProvider / OpenAICompatibleProvider / ConversationManager
|
已新增 LLMProvider / OpenAICompatibleProvider / GoogleGeminiProvider / ConversationManager
|
||||||
已支持 OpenAI Compatible 异步请求、超时、取消、错误提示和网络诊断日志
|
已支持 OpenAI Compatible 异步请求、超时、取消、错误提示和网络诊断日志
|
||||||
|
已支持 Google Gemini generateContent / streamGenerateContent、x-goog-api-key、contents 多轮上下文和 systemInstruction
|
||||||
已支持 SSE 流式输出,气泡中流式显示,历史面板只记录最终对话
|
已支持 SSE 流式输出,气泡中流式显示,历史面板只记录最终对话
|
||||||
已限制同一时间只允许一个 AI 请求
|
已限制同一时间只允许一个 AI 请求
|
||||||
已避免在日志中输出完整 API Key 和完整消息正文
|
已避免在日志中输出完整 API Key 和完整消息正文
|
||||||
@@ -563,7 +564,8 @@ error :20 帧
|
|||||||
已新增 ChatBubble、ChatInputDialog、ChatHistoryPanel、SettingsDialog
|
已新增 ChatBubble、ChatInputDialog、ChatHistoryPanel、SettingsDialog
|
||||||
已支持右键聊天、显示对话、取消 AI 请求、清空对话、设置
|
已支持右键聊天、显示对话、取消 AI 请求、清空对话、设置
|
||||||
已删除临时 AI 测试入口和气泡测试入口
|
已删除临时 AI 测试入口和气泡测试入口
|
||||||
已支持 OpenAI / Google / Claude / DeepSeek / Custom 配置分 Provider 保存
|
已支持 OpenAI / Google / DeepSeek / Custom 配置分 Provider 保存
|
||||||
|
已移除废弃 Provider 配置入口,并在读取旧配置时清理废弃 Provider 配置
|
||||||
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
|
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -571,14 +573,12 @@ error :20 帧
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. shiroko 角色包仍位于项目根目录 shiroko/,尚未移动到 resources/characters/shiroko
|
1. shiroko 角色包仍位于项目根目录 shiroko/,尚未移动到 resources/characters/shiroko
|
||||||
2. Google / Claude 目前只有配置入口,正式聊天运行时仍只接入 openai-compatible 协议
|
2. SettingsDialog 仍是最小设置界面,尚未包含 AI 测试按钮、应用设置、角色选择、缩放和性能模式 UI
|
||||||
3. SettingsDialog 仍是最小设置界面,尚未包含 AI 测试按钮、应用设置、角色选择、缩放和性能模式 UI
|
3. ConfigManager 已有缩放和性能字段,但 PetWindow 尚未真正应用缩放、性能模式和角色选择
|
||||||
4. ConfigManager 已有缩放和性能字段,但 PetWindow 尚未真正应用缩放、性能模式和角色选择
|
4. CharacterPackage 尚未解析并应用 character.json 中的 base、anchor、bubble offset
|
||||||
5. CharacterPackage 尚未解析并应用 character.json 中的 base、anchor、bubble offset
|
5. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪
|
||||||
6. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪
|
6. 当前 FrameAnimator 采用当前角色包全部状态帧预加载,尚未做懒加载
|
||||||
7. 当前 FrameAnimator 采用当前角色包全部状态帧预加载,尚未做懒加载
|
7. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
||||||
8. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
|
||||||
9. 最近一次流式状态修正已本地提交,推送时遇到远程认证失败,需要重新认证后推送
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -588,7 +588,11 @@ error :20 帧
|
|||||||
短期建议:
|
短期建议:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 解决远程仓库认证问题,并推送本地提交
|
1. 用户手测 Google Gemini Provider:
|
||||||
|
- Google Provider 配置保存
|
||||||
|
- Gemini 普通回复
|
||||||
|
- Gemini 流式回复
|
||||||
|
- 错误 Key / 错误模型错误提示
|
||||||
2. 用户手测流式状态修正:
|
2. 用户手测流式状态修正:
|
||||||
- 发送消息后等待阶段应保持 think
|
- 发送消息后等待阶段应保持 think
|
||||||
- 等待阶段拖动松开应回到 think
|
- 等待阶段拖动松开应回到 think
|
||||||
@@ -623,11 +627,9 @@ error :20 帧
|
|||||||
后续开始写代码前,需要逐项确认:
|
后续开始写代码前,需要逐项确认:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 远程仓库认证失败,当前本地提交尚未推送成功
|
1. 是否把 shiroko 移动到 resources/characters/shiroko
|
||||||
2. 是否把 shiroko 移动到 resources/characters/shiroko
|
2. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载
|
||||||
3. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载
|
3. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
||||||
4. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
4. 设置页下一步先做 AI 测试入口,还是先做应用缩放 / 性能设置
|
||||||
5. Google / Claude 是先禁用正式聊天提示,还是继续实现原生 Provider
|
5. 是否需要把对话历史持久化保存,还是第一版只保留内存会话
|
||||||
6. 设置页下一步先做 AI 测试入口,还是先做应用缩放 / 性能设置
|
|
||||||
7. 是否需要把对话历史持久化保存,还是第一版只保留内存会话
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -22,14 +22,6 @@ AIConfig defaultAIConfigForProvider(const QString &provider)
|
|||||||
return config;
|
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"))
|
if (normalizedProvider == QStringLiteral("google"))
|
||||||
{
|
{
|
||||||
config.protocol = QStringLiteral("google-generative-language");
|
config.protocol = QStringLiteral("google-generative-language");
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ QString normalizedProviderName(const QString &provider)
|
|||||||
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
|
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 objectFromAIProviderConfig(const AIConfig &config)
|
||||||
{
|
{
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
@@ -69,13 +76,21 @@ QJsonObject objectFromAIConfigStore(const AIConfigStore &store)
|
|||||||
for (auto iterator = store.providers.constBegin(); iterator != store.providers.constEnd(); ++iterator)
|
for (auto iterator = store.providers.constBegin(); iterator != store.providers.constEnd(); ++iterator)
|
||||||
{
|
{
|
||||||
const QString provider = normalizedProviderName(iterator.key());
|
const QString provider = normalizedProviderName(iterator.key());
|
||||||
|
if (isRemovedProviderName(provider))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
AIConfig config = iterator.value();
|
AIConfig config = iterator.value();
|
||||||
config.provider = provider;
|
config.provider = provider;
|
||||||
providers.insert(provider, objectFromAIProviderConfig(config));
|
providers.insert(provider, objectFromAIProviderConfig(config));
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject root;
|
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);
|
root.insert(QStringLiteral("providers"), providers);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -210,6 +225,12 @@ AIConfigStore ConfigManager::loadAIConfigStore() const
|
|||||||
|
|
||||||
const QJsonObject root = document.object();
|
const QJsonObject root = document.object();
|
||||||
store.activeProvider = normalizedProviderName(root.value(QStringLiteral("activeProvider")).toString(store.activeProvider));
|
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();
|
const QJsonObject providers = root.value(QStringLiteral("providers")).toObject();
|
||||||
for (auto iterator = providers.constBegin(); iterator != providers.constEnd(); ++iterator)
|
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 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()));
|
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;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +317,10 @@ bool ConfigManager::saveAIConfig(const AIConfig &config) const
|
|||||||
{
|
{
|
||||||
AIConfig normalizedConfig = config;
|
AIConfig normalizedConfig = config;
|
||||||
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
|
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
|
||||||
|
if (isRemovedProviderName(normalizedConfig.provider))
|
||||||
|
{
|
||||||
|
normalizedConfig = defaultAIConfigForProvider(QStringLiteral("custom"));
|
||||||
|
}
|
||||||
|
|
||||||
AIConfigStore store = loadAIConfigStore();
|
AIConfigStore store = loadAIConfigStore();
|
||||||
store.activeProvider = normalizedConfig.provider;
|
store.activeProvider = normalizedConfig.provider;
|
||||||
|
|||||||
+28
-2
@@ -1,6 +1,7 @@
|
|||||||
#include "PetWindow.h"
|
#include "PetWindow.h"
|
||||||
|
|
||||||
#include "../ai/ConversationManager.h"
|
#include "../ai/ConversationManager.h"
|
||||||
|
#include "../ai/GoogleGeminiProvider.h"
|
||||||
#include "../ai/OpenAICompatibleProvider.h"
|
#include "../ai/OpenAICompatibleProvider.h"
|
||||||
#include "../character/CharacterPackageLoader.h"
|
#include "../character/CharacterPackageLoader.h"
|
||||||
#include "../config/ConfigManager.h"
|
#include "../config/ConfigManager.h"
|
||||||
@@ -82,7 +83,9 @@ bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
|
|||||||
|
|
||||||
bool prepareRuntimeAIConfig(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 协议暂未接入。");
|
*errorMessage = QStringLiteral("当前 Provider 协议暂未接入。");
|
||||||
return false;
|
return false;
|
||||||
@@ -108,6 +111,21 @@ bool prepareRuntimeAIConfig(AIConfig &config, QString *errorMessage)
|
|||||||
return true;
|
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 userVisibleErrorMessage(const ChatResponse &response)
|
||||||
{
|
{
|
||||||
QString message = response.errorMessage.trimmed();
|
QString message = response.errorMessage.trimmed();
|
||||||
@@ -333,7 +351,15 @@ bool PetWindow::submitChatMessage(const QString &message)
|
|||||||
return false;
|
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 回复正在进行。"));
|
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ namespace
|
|||||||
QString normalizedProviderName(const QString &provider)
|
QString normalizedProviderName(const QString &provider)
|
||||||
{
|
{
|
||||||
const QString normalized = provider.trimmed().toLower();
|
const QString normalized = provider.trimmed().toLower();
|
||||||
|
if (normalized == QStringLiteral("claude") || normalized == QStringLiteral("calude"))
|
||||||
|
{
|
||||||
|
return QStringLiteral("custom");
|
||||||
|
}
|
||||||
|
|
||||||
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
|
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +48,6 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent
|
|||||||
const QList<QPair<QString, QString>> providers = {
|
const QList<QPair<QString, QString>> providers = {
|
||||||
{QStringLiteral("openai"), QStringLiteral("OpenAI")},
|
{QStringLiteral("openai"), QStringLiteral("OpenAI")},
|
||||||
{QStringLiteral("google"), QStringLiteral("Google")},
|
{QStringLiteral("google"), QStringLiteral("Google")},
|
||||||
{QStringLiteral("claude"), QStringLiteral("Claude")},
|
|
||||||
{QStringLiteral("deepseek"), QStringLiteral("DeepSeek")},
|
{QStringLiteral("deepseek"), QStringLiteral("DeepSeek")},
|
||||||
{QStringLiteral("custom"), QStringLiteral("Custom")},
|
{QStringLiteral("custom"), QStringLiteral("Custom")},
|
||||||
};
|
};
|
||||||
@@ -106,6 +110,8 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent
|
|||||||
AIConfigStore SettingsDialog::aiConfigStore() const
|
AIConfigStore SettingsDialog::aiConfigStore() const
|
||||||
{
|
{
|
||||||
AIConfigStore store = m_configStore;
|
AIConfigStore store = m_configStore;
|
||||||
|
store.providers.remove(QStringLiteral("claude"));
|
||||||
|
store.providers.remove(QStringLiteral("calude"));
|
||||||
const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString());
|
const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString());
|
||||||
store.activeProvider = provider;
|
store.activeProvider = provider;
|
||||||
store.providers.insert(provider, configFromForm(provider));
|
store.providers.insert(provider, configFromForm(provider));
|
||||||
|
|||||||
Reference in New Issue
Block a user