添加 AI 设置和密钥加密存储

This commit is contained in:
2026-05-29 08:41:07 +08:00
parent 5ece0ca30d
commit a4224a19b6
9 changed files with 393 additions and 6 deletions
+6
View File
@@ -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()
+45
View File
@@ -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;
}
+8 -2
View File
@@ -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);
+18 -4
View File
@@ -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);
+86
View File
@@ -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
}
+19
View File
@@ -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);
};
+14
View File
@@ -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();
+164
View File
@@ -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);
}
+33
View File
@@ -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;
};