添加 AI 设置和密钥加密存储
This commit is contained in:
@@ -23,9 +23,12 @@ qt_add_executable(QtDesktopPet
|
||||
src/character/FrameAnimator.h
|
||||
src/character/FrameAnimator.cpp
|
||||
src/config/AIConfig.h
|
||||
src/config/AIConfig.cpp
|
||||
src/config/AppConfig.h
|
||||
src/config/ConfigManager.h
|
||||
src/config/ConfigManager.cpp
|
||||
src/config/SecretStore.h
|
||||
src/config/SecretStore.cpp
|
||||
src/state/PetStateMachine.h
|
||||
src/state/PetStateMachine.cpp
|
||||
src/tray/TrayController.h
|
||||
@@ -34,6 +37,8 @@ qt_add_executable(QtDesktopPet
|
||||
src/ui/ChatBubble.cpp
|
||||
src/ui/PetView.h
|
||||
src/ui/PetView.cpp
|
||||
src/ui/SettingsDialog.h
|
||||
src/ui/SettingsDialog.cpp
|
||||
src/ui/PetWindow.h
|
||||
src/ui/PetWindow.cpp
|
||||
src/util/Logger.h
|
||||
@@ -52,4 +57,5 @@ target_link_libraries(QtDesktopPet
|
||||
|
||||
if (WIN32)
|
||||
target_compile_definitions(QtDesktopPet PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
|
||||
target_link_libraries(QtDesktopPet PRIVATE Crypt32)
|
||||
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
|
||||
{
|
||||
QString providerType = QStringLiteral("openai-compatible");
|
||||
QString provider = QStringLiteral("custom");
|
||||
QString protocol = QStringLiteral("openai-compatible");
|
||||
QString baseUrl;
|
||||
QString apiKey;
|
||||
QString model;
|
||||
QString path = QStringLiteral("/chat/completions");
|
||||
QString apiKeyStorage = QStringLiteral("none");
|
||||
QString apiKeyEncrypted;
|
||||
QString apiKey;
|
||||
bool allowPlainApiKey = false;
|
||||
bool stream = false;
|
||||
int timeoutMs = 60000;
|
||||
double temperature = 0.7;
|
||||
int maxTokens = 1024;
|
||||
};
|
||||
|
||||
AIConfig defaultAIConfigForProvider(const QString &provider);
|
||||
|
||||
@@ -37,11 +37,18 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
|
||||
QJsonObject objectFromAIConfig(const AIConfig &config)
|
||||
{
|
||||
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("apiKey"), config.apiKey);
|
||||
root.insert(QStringLiteral("model"), config.model);
|
||||
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("timeoutMs"), config.timeoutMs);
|
||||
root.insert(QStringLiteral("temperature"), config.temperature);
|
||||
@@ -144,11 +151,18 @@ AIConfig ConfigManager::loadAIConfig() const
|
||||
}
|
||||
|
||||
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.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey);
|
||||
config.model = root.value(QStringLiteral("model")).toString(config.model);
|
||||
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.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs);
|
||||
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 "../character/CharacterPackageLoader.h"
|
||||
#include "../config/ConfigManager.h"
|
||||
#include "../util/Logger.h"
|
||||
#include "ChatBubble.h"
|
||||
#include "PetView.h"
|
||||
#include "SettingsDialog.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QContextMenuEvent>
|
||||
#include <QCursor>
|
||||
#include <QDialog>
|
||||
#include <QGuiApplication>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
@@ -134,6 +137,8 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
|
||||
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
|
||||
|
||||
QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
|
||||
|
||||
addStateTestActions(&menu);
|
||||
|
||||
menu.addSeparator();
|
||||
@@ -156,6 +161,15 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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