接入 Google Gemini Provider
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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. 是否需要把对话历史持久化保存,还是第一版只保留内存会话
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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,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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user