From a4224a19b626f153c27c589a066416c182c160b9 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Fri, 29 May 2026 08:41:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20AI=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=92=8C=E5=AF=86=E9=92=A5=E5=8A=A0=E5=AF=86=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 6 ++ src/config/AIConfig.cpp | 45 ++++++++++ src/config/AIConfig.h | 10 ++- src/config/ConfigManager.cpp | 22 ++++- src/config/SecretStore.cpp | 86 ++++++++++++++++++ src/config/SecretStore.h | 19 ++++ src/ui/PetWindow.cpp | 14 +++ src/ui/SettingsDialog.cpp | 164 +++++++++++++++++++++++++++++++++++ src/ui/SettingsDialog.h | 33 +++++++ 9 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 src/config/AIConfig.cpp create mode 100644 src/config/SecretStore.cpp create mode 100644 src/config/SecretStore.h create mode 100644 src/ui/SettingsDialog.cpp create mode 100644 src/ui/SettingsDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ffc559c..8392e51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/src/config/AIConfig.cpp b/src/config/AIConfig.cpp new file mode 100644 index 0000000..d46eceb --- /dev/null +++ b/src/config/AIConfig.cpp @@ -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; +} diff --git a/src/config/AIConfig.h b/src/config/AIConfig.h index 3e2e3fc..ffda690 100644 --- a/src/config/AIConfig.h +++ b/src/config/AIConfig.h @@ -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); diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 35b4e96..a37a211 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -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); diff --git a/src/config/SecretStore.cpp b/src/config/SecretStore.cpp new file mode 100644 index 0000000..f0294e3 --- /dev/null +++ b/src/config/SecretStore.cpp @@ -0,0 +1,86 @@ +#include "SecretStore.h" + +#include + +#ifdef Q_OS_WIN +#include +#include + +#include +#include +#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(const_cast(plainBytes.constData())); + input.cbData = static_cast(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(output.pbData), static_cast(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(const_cast(encrypted.constData())); + input.cbData = static_cast(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(output.pbData), static_cast(output.cbData)); + return {true, QString::fromUtf8(plainBytes), {}}; +#else + Q_UNUSED(protectedText); + return {false, {}, QStringLiteral("Encrypted secret storage is not available on this platform.")}; +#endif +} diff --git a/src/config/SecretStore.h b/src/config/SecretStore.h new file mode 100644 index 0000000..054d3af --- /dev/null +++ b/src/config/SecretStore.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +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); +}; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 012f55d..8a72ea5 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -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 #include #include +#include #include #include #include @@ -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(); diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp new file mode 100644 index 0000000..2489527 --- /dev/null +++ b/src/ui/SettingsDialog.cpp @@ -0,0 +1,164 @@ +#include "SettingsDialog.h" + +#include "../config/SecretStore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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> 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); +} diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h new file mode 100644 index 0000000..3aadc62 --- /dev/null +++ b/src/ui/SettingsDialog.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../config/AIConfig.h" + +#include + +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; +};