添加 AI 设置和密钥加密存储
This commit is contained in:
@@ -23,9 +23,12 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/character/FrameAnimator.h
|
src/character/FrameAnimator.h
|
||||||
src/character/FrameAnimator.cpp
|
src/character/FrameAnimator.cpp
|
||||||
src/config/AIConfig.h
|
src/config/AIConfig.h
|
||||||
|
src/config/AIConfig.cpp
|
||||||
src/config/AppConfig.h
|
src/config/AppConfig.h
|
||||||
src/config/ConfigManager.h
|
src/config/ConfigManager.h
|
||||||
src/config/ConfigManager.cpp
|
src/config/ConfigManager.cpp
|
||||||
|
src/config/SecretStore.h
|
||||||
|
src/config/SecretStore.cpp
|
||||||
src/state/PetStateMachine.h
|
src/state/PetStateMachine.h
|
||||||
src/state/PetStateMachine.cpp
|
src/state/PetStateMachine.cpp
|
||||||
src/tray/TrayController.h
|
src/tray/TrayController.h
|
||||||
@@ -34,6 +37,8 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/ui/ChatBubble.cpp
|
src/ui/ChatBubble.cpp
|
||||||
src/ui/PetView.h
|
src/ui/PetView.h
|
||||||
src/ui/PetView.cpp
|
src/ui/PetView.cpp
|
||||||
|
src/ui/SettingsDialog.h
|
||||||
|
src/ui/SettingsDialog.cpp
|
||||||
src/ui/PetWindow.h
|
src/ui/PetWindow.h
|
||||||
src/ui/PetWindow.cpp
|
src/ui/PetWindow.cpp
|
||||||
src/util/Logger.h
|
src/util/Logger.h
|
||||||
@@ -52,4 +57,5 @@ target_link_libraries(QtDesktopPet
|
|||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
target_compile_definitions(QtDesktopPet PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
|
target_compile_definitions(QtDesktopPet PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
|
||||||
|
target_link_libraries(QtDesktopPet PRIVATE Crypt32)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "AIConfig.h"
|
||||||
|
|
||||||
|
AIConfig defaultAIConfigForProvider(const QString &provider)
|
||||||
|
{
|
||||||
|
AIConfig config;
|
||||||
|
const QString normalizedProvider = provider.trimmed().toLower();
|
||||||
|
config.provider = normalizedProvider;
|
||||||
|
|
||||||
|
if (normalizedProvider == QStringLiteral("openai"))
|
||||||
|
{
|
||||||
|
config.protocol = QStringLiteral("openai-compatible");
|
||||||
|
config.baseUrl = QStringLiteral("https://api.openai.com/v1");
|
||||||
|
config.path = QStringLiteral("/chat/completions");
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedProvider == QStringLiteral("deepseek"))
|
||||||
|
{
|
||||||
|
config.protocol = QStringLiteral("openai-compatible");
|
||||||
|
config.baseUrl = QStringLiteral("https://api.deepseek.com/v1");
|
||||||
|
config.path = QStringLiteral("/chat/completions");
|
||||||
|
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");
|
||||||
|
config.baseUrl = QStringLiteral("https://generativelanguage.googleapis.com");
|
||||||
|
config.path = QStringLiteral("/v1beta/models/{model}:generateContent");
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.provider = QStringLiteral("custom");
|
||||||
|
config.protocol = QStringLiteral("openai-compatible");
|
||||||
|
config.path = QStringLiteral("/chat/completions");
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -4,13 +4,19 @@
|
|||||||
|
|
||||||
struct AIConfig
|
struct AIConfig
|
||||||
{
|
{
|
||||||
QString providerType = QStringLiteral("openai-compatible");
|
QString provider = QStringLiteral("custom");
|
||||||
|
QString protocol = QStringLiteral("openai-compatible");
|
||||||
QString baseUrl;
|
QString baseUrl;
|
||||||
QString apiKey;
|
|
||||||
QString model;
|
QString model;
|
||||||
QString path = QStringLiteral("/chat/completions");
|
QString path = QStringLiteral("/chat/completions");
|
||||||
|
QString apiKeyStorage = QStringLiteral("none");
|
||||||
|
QString apiKeyEncrypted;
|
||||||
|
QString apiKey;
|
||||||
|
bool allowPlainApiKey = false;
|
||||||
bool stream = false;
|
bool stream = false;
|
||||||
int timeoutMs = 60000;
|
int timeoutMs = 60000;
|
||||||
double temperature = 0.7;
|
double temperature = 0.7;
|
||||||
int maxTokens = 1024;
|
int maxTokens = 1024;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
AIConfig defaultAIConfigForProvider(const QString &provider);
|
||||||
|
|||||||
@@ -37,11 +37,18 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
|
|||||||
QJsonObject objectFromAIConfig(const AIConfig &config)
|
QJsonObject objectFromAIConfig(const AIConfig &config)
|
||||||
{
|
{
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
root.insert(QStringLiteral("providerType"), config.providerType);
|
root.insert(QStringLiteral("provider"), config.provider);
|
||||||
|
root.insert(QStringLiteral("protocol"), config.protocol);
|
||||||
root.insert(QStringLiteral("baseUrl"), config.baseUrl);
|
root.insert(QStringLiteral("baseUrl"), config.baseUrl);
|
||||||
root.insert(QStringLiteral("apiKey"), config.apiKey);
|
|
||||||
root.insert(QStringLiteral("model"), config.model);
|
root.insert(QStringLiteral("model"), config.model);
|
||||||
root.insert(QStringLiteral("path"), config.path);
|
root.insert(QStringLiteral("path"), config.path);
|
||||||
|
root.insert(QStringLiteral("apiKeyStorage"), config.apiKeyStorage);
|
||||||
|
root.insert(QStringLiteral("apiKeyEncrypted"), config.apiKeyEncrypted);
|
||||||
|
root.insert(QStringLiteral("allowPlainApiKey"), config.allowPlainApiKey);
|
||||||
|
if (config.allowPlainApiKey && config.apiKeyStorage == QStringLiteral("plain-json"))
|
||||||
|
{
|
||||||
|
root.insert(QStringLiteral("apiKey"), config.apiKey);
|
||||||
|
}
|
||||||
root.insert(QStringLiteral("stream"), config.stream);
|
root.insert(QStringLiteral("stream"), config.stream);
|
||||||
root.insert(QStringLiteral("timeoutMs"), config.timeoutMs);
|
root.insert(QStringLiteral("timeoutMs"), config.timeoutMs);
|
||||||
root.insert(QStringLiteral("temperature"), config.temperature);
|
root.insert(QStringLiteral("temperature"), config.temperature);
|
||||||
@@ -144,11 +151,18 @@ AIConfig ConfigManager::loadAIConfig() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QJsonObject root = document.object();
|
const QJsonObject root = document.object();
|
||||||
config.providerType = root.value(QStringLiteral("providerType")).toString(config.providerType);
|
config.provider = root.value(QStringLiteral("provider")).toString(config.provider);
|
||||||
|
config.protocol = root.value(QStringLiteral("protocol")).toString(root.value(QStringLiteral("providerType")).toString(config.protocol));
|
||||||
config.baseUrl = root.value(QStringLiteral("baseUrl")).toString(config.baseUrl);
|
config.baseUrl = root.value(QStringLiteral("baseUrl")).toString(config.baseUrl);
|
||||||
config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey);
|
|
||||||
config.model = root.value(QStringLiteral("model")).toString(config.model);
|
config.model = root.value(QStringLiteral("model")).toString(config.model);
|
||||||
config.path = root.value(QStringLiteral("path")).toString(config.path);
|
config.path = root.value(QStringLiteral("path")).toString(config.path);
|
||||||
|
config.apiKeyStorage = root.value(QStringLiteral("apiKeyStorage")).toString(config.apiKeyStorage);
|
||||||
|
config.apiKeyEncrypted = root.value(QStringLiteral("apiKeyEncrypted")).toString(config.apiKeyEncrypted);
|
||||||
|
config.allowPlainApiKey = root.value(QStringLiteral("allowPlainApiKey")).toBool(config.allowPlainApiKey);
|
||||||
|
if (config.allowPlainApiKey && config.apiKeyStorage == QStringLiteral("plain-json"))
|
||||||
|
{
|
||||||
|
config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey);
|
||||||
|
}
|
||||||
config.stream = root.value(QStringLiteral("stream")).toBool(config.stream);
|
config.stream = root.value(QStringLiteral("stream")).toBool(config.stream);
|
||||||
config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs);
|
config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs);
|
||||||
config.temperature = root.value(QStringLiteral("temperature")).toDouble(config.temperature);
|
config.temperature = root.value(QStringLiteral("temperature")).toDouble(config.temperature);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#include "SecretStore.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
#include <QScopeGuard>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <dpapi.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
bool SecretStore::isEncryptionAvailable()
|
||||||
|
{
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SecretStore::preferredStorageName()
|
||||||
|
{
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
return QStringLiteral("windows-dpapi");
|
||||||
|
#else
|
||||||
|
return QStringLiteral("plain-json");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
SecretStore::Result SecretStore::protectText(const QString &plainText)
|
||||||
|
{
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
const QByteArray plainBytes = plainText.toUtf8();
|
||||||
|
DATA_BLOB input;
|
||||||
|
input.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(plainBytes.constData()));
|
||||||
|
input.cbData = static_cast<DWORD>(plainBytes.size());
|
||||||
|
|
||||||
|
DATA_BLOB output;
|
||||||
|
if (!CryptProtectData(&input, nullptr, nullptr, nullptr, nullptr, 0, &output))
|
||||||
|
{
|
||||||
|
return {false, {}, QStringLiteral("CryptProtectData failed.")};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto cleanup = qScopeGuard([&output]() {
|
||||||
|
LocalFree(output.pbData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const QByteArray encrypted(reinterpret_cast<const char *>(output.pbData), static_cast<int>(output.cbData));
|
||||||
|
return {true, QString::fromLatin1(encrypted.toBase64()), {}};
|
||||||
|
#else
|
||||||
|
Q_UNUSED(plainText);
|
||||||
|
return {false, {}, QStringLiteral("Encrypted secret storage is not available on this platform.")};
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
SecretStore::Result SecretStore::unprotectText(const QString &protectedText)
|
||||||
|
{
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
const QByteArray encrypted = QByteArray::fromBase64(protectedText.toLatin1());
|
||||||
|
if (encrypted.isEmpty() && !protectedText.isEmpty())
|
||||||
|
{
|
||||||
|
return {false, {}, QStringLiteral("Encrypted secret is not valid base64.")};
|
||||||
|
}
|
||||||
|
|
||||||
|
DATA_BLOB input;
|
||||||
|
input.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(encrypted.constData()));
|
||||||
|
input.cbData = static_cast<DWORD>(encrypted.size());
|
||||||
|
|
||||||
|
DATA_BLOB output;
|
||||||
|
if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, 0, &output))
|
||||||
|
{
|
||||||
|
return {false, {}, QStringLiteral("CryptUnprotectData failed.")};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto cleanup = qScopeGuard([&output]() {
|
||||||
|
LocalFree(output.pbData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const QByteArray plainBytes(reinterpret_cast<const char *>(output.pbData), static_cast<int>(output.cbData));
|
||||||
|
return {true, QString::fromUtf8(plainBytes), {}};
|
||||||
|
#else
|
||||||
|
Q_UNUSED(protectedText);
|
||||||
|
return {false, {}, QStringLiteral("Encrypted secret storage is not available on this platform.")};
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class SecretStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct Result
|
||||||
|
{
|
||||||
|
bool success = false;
|
||||||
|
QString value;
|
||||||
|
QString errorMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool isEncryptionAvailable();
|
||||||
|
static QString preferredStorageName();
|
||||||
|
static Result protectText(const QString &plainText);
|
||||||
|
static Result unprotectText(const QString &protectedText);
|
||||||
|
};
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
#include "PetWindow.h"
|
#include "PetWindow.h"
|
||||||
|
|
||||||
#include "../character/CharacterPackageLoader.h"
|
#include "../character/CharacterPackageLoader.h"
|
||||||
|
#include "../config/ConfigManager.h"
|
||||||
#include "../util/Logger.h"
|
#include "../util/Logger.h"
|
||||||
#include "ChatBubble.h"
|
#include "ChatBubble.h"
|
||||||
#include "PetView.h"
|
#include "PetView.h"
|
||||||
|
#include "SettingsDialog.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QContextMenuEvent>
|
#include <QContextMenuEvent>
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
|
#include <QDialog>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
@@ -134,6 +137,8 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
||||||
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
||||||
|
|
||||||
|
QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
|
||||||
|
|
||||||
addStateTestActions(&menu);
|
addStateTestActions(&menu);
|
||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
@@ -156,6 +161,15 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
{
|
{
|
||||||
showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。"));
|
showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。"));
|
||||||
}
|
}
|
||||||
|
else if (selectedAction == settingsAction)
|
||||||
|
{
|
||||||
|
ConfigManager configManager;
|
||||||
|
SettingsDialog dialog(configManager.loadAIConfig(), this);
|
||||||
|
if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfig(dialog.aiConfig()))
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (selectedAction == exitAction)
|
else if (selectedAction == exitAction)
|
||||||
{
|
{
|
||||||
close();
|
close();
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#include "SettingsDialog.h"
|
||||||
|
|
||||||
|
#include "../config/SecretStore.h"
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QList>
|
||||||
|
#include <QPair>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
|
||||||
|
: QDialog(parent)
|
||||||
|
, m_providerComboBox(new QComboBox(this))
|
||||||
|
, m_baseUrlEdit(new QLineEdit(this))
|
||||||
|
, m_apiKeyEdit(new QLineEdit(this))
|
||||||
|
, m_modelEdit(new QLineEdit(this))
|
||||||
|
, m_pathEdit(new QLineEdit(this))
|
||||||
|
, m_timeoutSpinBox(new QSpinBox(this))
|
||||||
|
, m_temperatureSpinBox(new QDoubleSpinBox(this))
|
||||||
|
, m_maxTokensSpinBox(new QSpinBox(this))
|
||||||
|
, m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this))
|
||||||
|
, m_initialConfig(config)
|
||||||
|
{
|
||||||
|
setWindowTitle(QStringLiteral("设置"));
|
||||||
|
setModal(true);
|
||||||
|
resize(460, 360);
|
||||||
|
|
||||||
|
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")},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto &provider : providers)
|
||||||
|
{
|
||||||
|
m_providerComboBox->addItem(provider.second, provider.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int providerIndex = m_providerComboBox->findData(config.provider);
|
||||||
|
m_providerComboBox->setCurrentIndex(providerIndex >= 0 ? providerIndex : m_providerComboBox->findData(QStringLiteral("custom")));
|
||||||
|
|
||||||
|
QString apiKey = config.apiKey;
|
||||||
|
if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty())
|
||||||
|
{
|
||||||
|
const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted);
|
||||||
|
if (result.success)
|
||||||
|
{
|
||||||
|
apiKey = result.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_baseUrlEdit->setText(config.baseUrl);
|
||||||
|
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
|
||||||
|
m_apiKeyEdit->setText(apiKey);
|
||||||
|
m_modelEdit->setText(config.model);
|
||||||
|
m_pathEdit->setText(config.path);
|
||||||
|
|
||||||
|
m_timeoutSpinBox->setRange(1000, 300000);
|
||||||
|
m_timeoutSpinBox->setSingleStep(1000);
|
||||||
|
m_timeoutSpinBox->setValue(config.timeoutMs);
|
||||||
|
|
||||||
|
m_temperatureSpinBox->setRange(0.0, 2.0);
|
||||||
|
m_temperatureSpinBox->setSingleStep(0.1);
|
||||||
|
m_temperatureSpinBox->setDecimals(2);
|
||||||
|
m_temperatureSpinBox->setValue(config.temperature);
|
||||||
|
|
||||||
|
m_maxTokensSpinBox->setRange(1, 200000);
|
||||||
|
m_maxTokensSpinBox->setValue(config.maxTokens);
|
||||||
|
|
||||||
|
m_allowPlainApiKeyCheckBox->setChecked(config.allowPlainApiKey);
|
||||||
|
m_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable());
|
||||||
|
|
||||||
|
auto *formLayout = new QFormLayout();
|
||||||
|
formLayout->addRow(QStringLiteral("服务商"), m_providerComboBox);
|
||||||
|
formLayout->addRow(QStringLiteral("Base URL"), m_baseUrlEdit);
|
||||||
|
formLayout->addRow(QStringLiteral("API Key"), m_apiKeyEdit);
|
||||||
|
formLayout->addRow(QStringLiteral("Model"), m_modelEdit);
|
||||||
|
formLayout->addRow(QStringLiteral("Path"), m_pathEdit);
|
||||||
|
formLayout->addRow(QStringLiteral("Timeout"), m_timeoutSpinBox);
|
||||||
|
formLayout->addRow(QStringLiteral("Temperature"), m_temperatureSpinBox);
|
||||||
|
formLayout->addRow(QStringLiteral("Max Tokens"), m_maxTokensSpinBox);
|
||||||
|
formLayout->addRow(QString(), m_allowPlainApiKeyCheckBox);
|
||||||
|
|
||||||
|
auto *storageHintLabel = new QLabel(this);
|
||||||
|
storageHintLabel->setWordWrap(true);
|
||||||
|
storageHintLabel->setText(SecretStore::isEncryptionAvailable()
|
||||||
|
? QStringLiteral("API Key 将使用 Windows DPAPI 加密后保存。")
|
||||||
|
: QStringLiteral("当前平台不支持内置加密。只有勾选确认后才会明文保存 API Key。"));
|
||||||
|
|
||||||
|
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this);
|
||||||
|
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
|
||||||
|
auto *layout = new QVBoxLayout(this);
|
||||||
|
layout->addLayout(formLayout);
|
||||||
|
layout->addWidget(storageHintLabel);
|
||||||
|
layout->addWidget(buttonBox);
|
||||||
|
|
||||||
|
connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() {
|
||||||
|
applyProviderPreset(m_providerComboBox->currentData().toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
AIConfig SettingsDialog::aiConfig() const
|
||||||
|
{
|
||||||
|
AIConfig config = m_initialConfig;
|
||||||
|
config.provider = m_providerComboBox->currentData().toString();
|
||||||
|
config.protocol = defaultAIConfigForProvider(config.provider).protocol;
|
||||||
|
config.baseUrl = m_baseUrlEdit->text().trimmed();
|
||||||
|
config.model = m_modelEdit->text().trimmed();
|
||||||
|
config.path = m_pathEdit->text().trimmed();
|
||||||
|
config.timeoutMs = m_timeoutSpinBox->value();
|
||||||
|
config.temperature = m_temperatureSpinBox->value();
|
||||||
|
config.maxTokens = m_maxTokensSpinBox->value();
|
||||||
|
|
||||||
|
const QString apiKey = m_apiKeyEdit->text();
|
||||||
|
config.apiKey.clear();
|
||||||
|
config.apiKeyEncrypted.clear();
|
||||||
|
config.allowPlainApiKey = m_allowPlainApiKeyCheckBox->isChecked();
|
||||||
|
|
||||||
|
if (apiKey.isEmpty())
|
||||||
|
{
|
||||||
|
config.apiKeyStorage = QStringLiteral("none");
|
||||||
|
}
|
||||||
|
else if (SecretStore::isEncryptionAvailable())
|
||||||
|
{
|
||||||
|
const SecretStore::Result result = SecretStore::protectText(apiKey);
|
||||||
|
if (result.success)
|
||||||
|
{
|
||||||
|
config.apiKeyStorage = SecretStore::preferredStorageName();
|
||||||
|
config.apiKeyEncrypted = result.value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.apiKeyStorage = QStringLiteral("none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (config.allowPlainApiKey)
|
||||||
|
{
|
||||||
|
config.apiKeyStorage = QStringLiteral("plain-json");
|
||||||
|
config.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.apiKeyStorage = QStringLiteral("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::applyProviderPreset(const QString &provider)
|
||||||
|
{
|
||||||
|
const AIConfig preset = defaultAIConfigForProvider(provider);
|
||||||
|
m_baseUrlEdit->setText(preset.baseUrl);
|
||||||
|
m_pathEdit->setText(preset.path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../config/AIConfig.h"
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
|
||||||
|
class QCheckBox;
|
||||||
|
class QComboBox;
|
||||||
|
class QDoubleSpinBox;
|
||||||
|
class QLineEdit;
|
||||||
|
class QSpinBox;
|
||||||
|
|
||||||
|
class SettingsDialog : public QDialog
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SettingsDialog(const AIConfig &config, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
AIConfig aiConfig() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void applyProviderPreset(const QString &provider);
|
||||||
|
|
||||||
|
QComboBox *m_providerComboBox = nullptr;
|
||||||
|
QLineEdit *m_baseUrlEdit = nullptr;
|
||||||
|
QLineEdit *m_apiKeyEdit = nullptr;
|
||||||
|
QLineEdit *m_modelEdit = nullptr;
|
||||||
|
QLineEdit *m_pathEdit = nullptr;
|
||||||
|
QSpinBox *m_timeoutSpinBox = nullptr;
|
||||||
|
QDoubleSpinBox *m_temperatureSpinBox = nullptr;
|
||||||
|
QSpinBox *m_maxTokensSpinBox = nullptr;
|
||||||
|
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
|
||||||
|
AIConfig m_initialConfig;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user