From 46c4f6092b9879793e756ecd82412693bce7d705 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Sat, 30 May 2026 03:48:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 2 + main.cpp | 1 + src/ai/AIProviderFactory.cpp | 87 +++++++++ src/ai/AIProviderFactory.h | 13 ++ src/tray/TrayController.cpp | 15 ++ src/tray/TrayController.h | 1 + src/ui/PetWindow.cpp | 139 ++++----------- src/ui/PetWindow.h | 3 + src/ui/SettingsDialog.cpp | 330 ++++++++++++++++++++++++++++++++--- src/ui/SettingsDialog.h | 21 ++- 10 files changed, 485 insertions(+), 127 deletions(-) create mode 100644 src/ai/AIProviderFactory.cpp create mode 100644 src/ai/AIProviderFactory.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7622910..d3df0f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network) qt_add_executable(QtDesktopPet main.cpp + src/ai/AIProviderFactory.h + src/ai/AIProviderFactory.cpp src/ai/ConversationManager.h src/ai/ConversationManager.cpp src/ai/LLMProvider.h diff --git a/main.cpp b/main.cpp index 182b63c..ade57c2 100644 --- a/main.cpp +++ b/main.cpp @@ -20,6 +20,7 @@ int main(int argc, char *argv[]) window.applyAppConfig(configManager.loadAppConfig()); TrayController trayController(&window); + window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable()); trayController.show(); QObject::connect(&app, &QCoreApplication::aboutToQuit, [&configManager, &window]() { diff --git a/src/ai/AIProviderFactory.cpp b/src/ai/AIProviderFactory.cpp new file mode 100644 index 0000000..ca4c739 --- /dev/null +++ b/src/ai/AIProviderFactory.cpp @@ -0,0 +1,87 @@ +#include "AIProviderFactory.h" + +#include "../config/SecretStore.h" +#include "GoogleGeminiProvider.h" +#include "OpenAICompatibleProvider.h" + +namespace +{ +bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage) +{ + if (!config.apiKey.trimmed().isEmpty()) + { + return true; + } + + if (config.apiKeyStorage == QStringLiteral("windows-dpapi")) + { + if (config.apiKeyEncrypted.trimmed().isEmpty()) + { + *errorMessage = QStringLiteral("请先在设置里配置 API Key。"); + return false; + } + + const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted); + if (!result.success) + { + *errorMessage = QStringLiteral("API Key 解密失败:") + result.errorMessage; + return false; + } + + config.apiKey = result.value; + } + + if (config.apiKey.trimmed().isEmpty()) + { + *errorMessage = QStringLiteral("请先在设置里配置 API Key。"); + return false; + } + + return true; +} +} + +bool AIProviderFactory::prepareRuntimeConfig(AIConfig &config, QString *errorMessage) +{ + const bool supportedProtocol = config.protocol == QStringLiteral("openai-compatible") + || config.protocol == QStringLiteral("google-generative-language"); + if (!supportedProtocol) + { + *errorMessage = QStringLiteral("当前 Provider 协议暂未接入。"); + return false; + } + + if (!populateRuntimeApiKey(config, errorMessage)) + { + return false; + } + + if (config.baseUrl.trimmed().isEmpty()) + { + *errorMessage = QStringLiteral("请先在设置里配置 Base URL。"); + return false; + } + + if (config.model.trimmed().isEmpty()) + { + *errorMessage = QStringLiteral("请先在设置里配置 Model。"); + return false; + } + + return true; +} + +std::unique_ptr AIProviderFactory::createProvider(const AIConfig &config) +{ + if (config.protocol == QStringLiteral("google-generative-language")) + { + return std::make_unique(config); + } + + if (config.protocol == QStringLiteral("openai-compatible")) + { + return std::make_unique(config); + } + + return nullptr; +} diff --git a/src/ai/AIProviderFactory.h b/src/ai/AIProviderFactory.h new file mode 100644 index 0000000..2c91c64 --- /dev/null +++ b/src/ai/AIProviderFactory.h @@ -0,0 +1,13 @@ +#pragma once + +#include "../config/AIConfig.h" +#include "LLMProvider.h" + +#include + +class AIProviderFactory +{ +public: + static bool prepareRuntimeConfig(AIConfig &config, QString *errorMessage); + static std::unique_ptr createProvider(const AIConfig &config); +}; diff --git a/src/tray/TrayController.cpp b/src/tray/TrayController.cpp index 3466a47..e4a8776 100644 --- a/src/tray/TrayController.cpp +++ b/src/tray/TrayController.cpp @@ -70,6 +70,11 @@ void TrayController::createMenu() hidePetWindow(); }); + QAction *settingsAction = m_menu.addAction(QStringLiteral("设置...")); + QObject::connect(settingsAction, &QAction::triggered, [this]() { + openSettings(); + }); + m_menu.addSeparator(); QAction *quitAction = m_menu.addAction(QStringLiteral("退出")); @@ -117,6 +122,16 @@ void TrayController::togglePetWindowVisibility() showPetWindow(); } +void TrayController::openSettings() +{ + if (m_petWindow == nullptr) + { + return; + } + + m_petWindow->openSettingsDialog(); +} + void TrayController::quitApplication() { if (m_petWindow != nullptr) diff --git a/src/tray/TrayController.h b/src/tray/TrayController.h index 9d0ca15..fa91b7a 100644 --- a/src/tray/TrayController.h +++ b/src/tray/TrayController.h @@ -18,6 +18,7 @@ private: void showPetWindow(); void hidePetWindow(); void togglePetWindowVisibility(); + void openSettings(); void quitApplication(); PetWindow *m_petWindow = nullptr; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 3db02d8..1c287ac 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -1,11 +1,9 @@ #include "PetWindow.h" #include "../ai/ConversationManager.h" -#include "../ai/GoogleGeminiProvider.h" -#include "../ai/OpenAICompatibleProvider.h" +#include "../ai/AIProviderFactory.h" #include "../character/CharacterPackageLoader.h" #include "../config/ConfigManager.h" -#include "../config/SecretStore.h" #include "../util/Logger.h" #include "ChatBubble.h" #include "ChatHistoryPanel.h" @@ -62,85 +60,6 @@ AppConfig normalizedAppConfig(AppConfig config) return config; } -bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage) -{ - if (!config.apiKey.trimmed().isEmpty()) - { - return true; - } - - if (config.apiKeyStorage == QStringLiteral("windows-dpapi")) - { - if (config.apiKeyEncrypted.trimmed().isEmpty()) - { - *errorMessage = QStringLiteral("请先在设置里配置 API Key。"); - return false; - } - - const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted); - if (!result.success) - { - *errorMessage = QStringLiteral("API Key 解密失败:") + result.errorMessage; - return false; - } - - config.apiKey = result.value; - } - - if (config.apiKey.trimmed().isEmpty()) - { - *errorMessage = QStringLiteral("请先在设置里配置 API Key。"); - return false; - } - - return true; -} - -bool prepareRuntimeAIConfig(AIConfig &config, QString *errorMessage) -{ - const bool supportedProtocol = config.protocol == QStringLiteral("openai-compatible") - || config.protocol == QStringLiteral("google-generative-language"); - if (!supportedProtocol) - { - *errorMessage = QStringLiteral("当前 Provider 协议暂未接入。"); - return false; - } - - if (!populateRuntimeApiKey(config, errorMessage)) - { - return false; - } - - if (config.baseUrl.trimmed().isEmpty()) - { - *errorMessage = QStringLiteral("请先在设置里配置 Base URL。"); - return false; - } - - if (config.model.trimmed().isEmpty()) - { - *errorMessage = QStringLiteral("请先在设置里配置 Model。"); - return false; - } - - return true; -} - -std::unique_ptr createProvider(const AIConfig &config) -{ - if (config.protocol == QStringLiteral("google-generative-language")) - { - return std::make_unique(config); - } - - if (config.protocol == QStringLiteral("openai-compatible")) - { - return std::make_unique(config); - } - - return nullptr; -} - QString userVisibleErrorMessage(const ChatResponse &response) { QString message = response.errorMessage.trimmed(); @@ -289,6 +208,34 @@ void PetWindow::showBubbleMessage(const QString &message) m_chatBubble->showMessage(message, bubbleAnchorPosition()); } +void PetWindow::openSettingsDialog() +{ + ConfigManager configManager; + SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() { + return isManualStateSwitchLocked(); + }, this); + if (dialog.exec() != QDialog::Accepted) + { + return; + } + + if (!configManager.saveAIConfigStore(dialog.aiConfigStore())) + { + Logger::warning(QStringLiteral("Failed to save AI config from settings dialog.")); + } + + applyAppConfig(dialog.appConfig()); + if (!configManager.saveAppConfig(currentAppConfig())) + { + Logger::warning(QStringLiteral("Failed to save app config from settings dialog.")); + } +} + +void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled) +{ + m_settingsFallbackInContextMenuEnabled = enabled; +} + void PetWindow::contextMenuEvent(QContextMenuEvent *event) { resetBubbleAutoHideTimer(); @@ -307,7 +254,11 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) cancelAIAction->setEnabled(aiRequestRunning); QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话")); clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory()); - QAction *settingsAction = menu.addAction(QStringLiteral("设置")); + QAction *settingsAction = nullptr; + if (m_settingsFallbackInContextMenuEnabled) + { + settingsAction = menu.addAction(QStringLiteral("设置")); + } addStateTestActions(&menu); @@ -335,23 +286,9 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) { clearConversation(); } - else if (selectedAction == settingsAction) + else if (settingsAction != nullptr && selectedAction == settingsAction) { - ConfigManager configManager; - SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), this); - if (dialog.exec() == QDialog::Accepted) - { - if (!configManager.saveAIConfigStore(dialog.aiConfigStore())) - { - Logger::warning(QStringLiteral("Failed to save AI config from settings dialog.")); - } - - applyAppConfig(dialog.appConfig()); - if (!configManager.saveAppConfig(currentAppConfig())) - { - Logger::warning(QStringLiteral("Failed to save app config from settings dialog.")); - } - } + openSettingsDialog(); } else if (selectedAction == exitAction) { @@ -402,14 +339,14 @@ bool PetWindow::submitChatMessage(const QString &message) ConfigManager configManager; AIConfig config = configManager.loadAIConfig(); QString errorMessage; - if (!prepareRuntimeAIConfig(config, &errorMessage)) + if (!AIProviderFactory::prepareRuntimeConfig(config, &errorMessage)) { playState(QStringLiteral("error"), false); showBubbleMessage(errorMessage); return false; } - std::unique_ptr provider = createProvider(config); + std::unique_ptr provider = AIProviderFactory::createProvider(config); if (!provider) { playState(QStringLiteral("error"), false); diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index db47315..ae01815 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -31,6 +31,8 @@ public: void applyAppConfig(const AppConfig &config); AppConfig currentAppConfig() const; + void openSettingsDialog(); + void setSettingsFallbackInContextMenuEnabled(bool enabled); void pauseAnimation(); void resumeAnimation(); void showBubbleMessage(const QString &message); @@ -98,4 +100,5 @@ private: bool m_returnToIdleAfterResume = false; bool m_streamingChatActive = false; bool m_streamingTalkStarted = false; + bool m_settingsFallbackInContextMenuEnabled = true; }; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 3b4e889..8da349f 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -1,5 +1,7 @@ #include "SettingsDialog.h" +#include "../ai/AIProviderFactory.h" +#include "../ai/LLMTypes.h" #include "../config/SecretStore.h" #include @@ -7,16 +9,25 @@ #include #include #include +#include +#include #include #include #include +#include +#include #include +#include +#include #include -#include +#include +#include #include #include #include +#include + namespace { QString normalizedProviderName(const QString &provider) @@ -29,9 +40,29 @@ QString normalizedProviderName(const QString &provider) return normalized.isEmpty() ? QStringLiteral("custom") : normalized; } + +QString userVisibleErrorMessage(const ChatResponse &response) +{ + QString message = response.errorMessage.trimmed(); + if (message.isEmpty()) + { + message = QStringLiteral("未知错误。"); + } + + if (response.httpStatus > 0) + { + message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral(":") + message; + } + + return message; +} } -SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig &appConfig, QWidget *parent) +SettingsDialog::SettingsDialog( + const AIConfigStore &configStore, + const AppConfig &appConfig, + std::function aiTestBlocked, + QWidget *parent) : QDialog(parent) , m_providerComboBox(new QComboBox(this)) , m_baseUrlEdit(new QLineEdit(this)) @@ -41,17 +72,20 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig , m_timeoutSpinBox(new QSpinBox(this)) , m_temperatureSpinBox(new QDoubleSpinBox(this)) , m_maxTokensSpinBox(new QSpinBox(this)) + , m_testConnectionButton(new QPushButton(QStringLiteral("测试连接"), this)) , m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this)) , m_scaleSpinBox(new QSpinBox(this)) , m_performanceModeComboBox(new QComboBox(this)) , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) , m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this)) + , m_characterComboBox(new QComboBox(this)) , m_configStore(configStore) , m_appConfig(appConfig) + , m_aiTestBlocked(std::move(aiTestBlocked)) { setWindowTitle(QStringLiteral("设置")); setModal(true); - resize(500, 430); + resize(760, 520); const QList> providers = { {QStringLiteral("openai"), QStringLiteral("OpenAI")}, @@ -97,51 +131,186 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString()); loadProviderConfig(m_currentProvider); - 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); + m_testStatusLabel = new QLabel(QStringLiteral("未测试"), this); + m_testStatusLabel->setObjectName(QStringLiteral("TestStatusLabel")); + m_testStatusLabel->setWordWrap(true); + + auto *aiTitleLabel = new QLabel(QStringLiteral("AI 配置"), this); + aiTitleLabel->setObjectName(QStringLiteral("PageTitle")); + + auto *aiFormLayout = new QFormLayout(); + aiFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + aiFormLayout->setLabelAlignment(Qt::AlignRight); + aiFormLayout->setFormAlignment(Qt::AlignTop); + aiFormLayout->setHorizontalSpacing(18); + aiFormLayout->setVerticalSpacing(12); + aiFormLayout->addRow(QStringLiteral("服务商"), m_providerComboBox); + aiFormLayout->addRow(QStringLiteral("Base URL"), m_baseUrlEdit); + aiFormLayout->addRow(QStringLiteral("API Key"), m_apiKeyEdit); + aiFormLayout->addRow(QStringLiteral("Model"), m_modelEdit); + aiFormLayout->addRow(QStringLiteral("Path"), m_pathEdit); + aiFormLayout->addRow(QStringLiteral("Timeout"), m_timeoutSpinBox); + aiFormLayout->addRow(QStringLiteral("Temperature"), m_temperatureSpinBox); + aiFormLayout->addRow(QStringLiteral("Max Tokens"), m_maxTokensSpinBox); + aiFormLayout->addRow(QString(), m_allowPlainApiKeyCheckBox); + + auto *storageHintLabel = new QLabel(this); + storageHintLabel->setObjectName(QStringLiteral("HintLabel")); + storageHintLabel->setWordWrap(true); + storageHintLabel->setText(SecretStore::isEncryptionAvailable() + ? QStringLiteral("API Key 将使用 Windows DPAPI 加密后保存。") + : QStringLiteral("当前平台不支持内置加密。只有勾选确认后才会明文保存 API Key。")); + + auto *aiActionLayout = new QHBoxLayout(); + aiActionLayout->addWidget(m_testConnectionButton); + aiActionLayout->addWidget(m_testStatusLabel, 1); + + auto *aiPageLayout = new QVBoxLayout(); + aiPageLayout->setContentsMargins(24, 24, 24, 24); + aiPageLayout->setSpacing(16); + aiPageLayout->addWidget(aiTitleLabel); + aiPageLayout->addLayout(aiFormLayout); + aiPageLayout->addWidget(storageHintLabel); + aiPageLayout->addLayout(aiActionLayout); + aiPageLayout->addStretch(); auto *aiPage = new QWidget(this); - aiPage->setLayout(formLayout); + aiPage->setLayout(aiPageLayout); + + auto *appTitleLabel = new QLabel(QStringLiteral("应用设置"), this); + appTitleLabel->setObjectName(QStringLiteral("PageTitle")); auto *appFormLayout = new QFormLayout(); + appFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + appFormLayout->setLabelAlignment(Qt::AlignRight); + appFormLayout->setFormAlignment(Qt::AlignTop); + appFormLayout->setHorizontalSpacing(18); + appFormLayout->setVerticalSpacing(12); appFormLayout->addRow(QStringLiteral("缩放"), m_scaleSpinBox); appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox); appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox); appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox); + auto *appPageLayout = new QVBoxLayout(); + appPageLayout->setContentsMargins(24, 24, 24, 24); + appPageLayout->setSpacing(16); + appPageLayout->addWidget(appTitleLabel); + appPageLayout->addLayout(appFormLayout); + appPageLayout->addStretch(); + auto *appPage = new QWidget(this); - appPage->setLayout(appFormLayout); + appPage->setLayout(appPageLayout); - auto *tabWidget = new QTabWidget(this); - tabWidget->addTab(aiPage, QStringLiteral("AI")); - tabWidget->addTab(appPage, QStringLiteral("应用")); + m_characterComboBox->addItem(QStringLiteral("shiroko"), QStringLiteral("shiroko")); + m_characterComboBox->setEnabled(false); + m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。")); - auto *storageHintLabel = new QLabel(this); - storageHintLabel->setWordWrap(true); - storageHintLabel->setText(SecretStore::isEncryptionAvailable() - ? QStringLiteral("API Key 将使用 Windows DPAPI 加密后保存。") - : QStringLiteral("当前平台不支持内置加密。只有勾选确认后才会明文保存 API Key。")); + auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this); + characterTitleLabel->setObjectName(QStringLiteral("PageTitle")); + auto *characterHintLabel = new QLabel(QStringLiteral("当前版本使用内置角色资源。"), this); + characterHintLabel->setObjectName(QStringLiteral("HintLabel")); + characterHintLabel->setWordWrap(true); + + auto *characterFormLayout = new QFormLayout(); + characterFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + characterFormLayout->setLabelAlignment(Qt::AlignRight); + characterFormLayout->setHorizontalSpacing(18); + characterFormLayout->setVerticalSpacing(12); + characterFormLayout->addRow(QStringLiteral("当前角色"), m_characterComboBox); + + auto *characterPageLayout = new QVBoxLayout(); + characterPageLayout->setContentsMargins(24, 24, 24, 24); + characterPageLayout->setSpacing(16); + characterPageLayout->addWidget(characterTitleLabel); + characterPageLayout->addLayout(characterFormLayout); + characterPageLayout->addWidget(characterHintLabel); + characterPageLayout->addStretch(); + + auto *characterPage = new QWidget(this); + characterPage->setLayout(characterPageLayout); + + auto *navigationList = new QListWidget(this); + navigationList->setObjectName(QStringLiteral("SettingsNavigation")); + navigationList->setFixedWidth(150); + navigationList->setFrameShape(QFrame::NoFrame); + navigationList->setSpacing(4); + navigationList->addItem(QStringLiteral("AI 配置")); + navigationList->addItem(QStringLiteral("应用")); + navigationList->addItem(QStringLiteral("角色")); + + auto *pageStack = new QStackedWidget(this); + pageStack->setObjectName(QStringLiteral("SettingsPages")); + pageStack->addWidget(aiPage); + pageStack->addWidget(appPage); + pageStack->addWidget(characterPage); + + connect(navigationList, &QListWidget::currentRowChanged, pageStack, &QStackedWidget::setCurrentIndex); + navigationList->setCurrentRow(0); + + auto *contentLayout = new QHBoxLayout(); + contentLayout->setContentsMargins(0, 0, 0, 0); + contentLayout->setSpacing(12); + contentLayout->addWidget(navigationList); + contentLayout->addWidget(pageStack, 1); 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->addWidget(tabWidget); - layout->addWidget(storageHintLabel); + layout->setContentsMargins(16, 16, 16, 16); + layout->setSpacing(14); + layout->addLayout(contentLayout, 1); layout->addWidget(buttonBox); + setStyleSheet(QStringLiteral( + "QDialog { background: #f8fafc; }" + "QLabel#PageTitle { color: #111827; font-size: 18px; font-weight: 600; }" + "QLabel#HintLabel { color: #667085; }" + "QLabel#TestStatusLabel { color: #667085; padding-left: 8px; }" + "QLabel#TestStatusLabel[status=\"error\"] { color: #b42318; }" + "QLabel#TestStatusLabel[status=\"success\"] { color: #067647; }" + "QLabel#TestStatusLabel[status=\"info\"] { color: #175cd3; }" + "QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {" + " min-height: 30px; border: 1px solid #d0d5dd; border-radius: 6px;" + " padding: 4px 8px; background: #ffffff;" + "}" + "QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {" + " border: 1px solid #2e90fa;" + "}" + "QListWidget#SettingsNavigation {" + " background: transparent; border: none; padding: 4px;" + "}" + "QListWidget#SettingsNavigation::item {" + " min-height: 34px; padding: 6px 10px; border-radius: 6px; color: #475467;" + "}" + "QListWidget#SettingsNavigation::item:selected {" + " background: #eaf3ff; color: #175cd3; font-weight: 600;" + "}" + "QStackedWidget#SettingsPages {" + " background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;" + "}" + "QPushButton {" + " min-height: 30px; border: 1px solid #d0d5dd; border-radius: 6px;" + " padding: 4px 14px; background: #ffffff; color: #344054;" + "}" + "QPushButton:disabled { color: #98a2b3; background: #f2f4f7; }" + "QPushButton:hover:enabled { background: #f9fafb; }")); + connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() { switchProvider(m_providerComboBox->currentData().toString()); }); + connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() { + testConnection(); + }); +} + +SettingsDialog::~SettingsDialog() +{ + if (m_testProvider) + { + m_testProvider->cancel(); + } } AIConfigStore SettingsDialog::aiConfigStore() const @@ -202,6 +371,13 @@ void SettingsDialog::switchProvider(const QString &provider) return; } + if (m_testProvider && m_testProvider->isBusy()) + { + m_testProvider->cancel(); + m_testConnectionButton->setEnabled(true); + setTestStatus(QStringLiteral("测试已取消。"), QStringLiteral("info")); + } + cacheCurrentProvider(); loadProviderConfig(normalizedProvider); m_currentProvider = normalizedProvider; @@ -255,6 +431,38 @@ AIConfig SettingsDialog::configFromForm(const QString &provider) const return config; } +AIConfig SettingsDialog::runtimeConfigFromForm(const QString &provider) const +{ + const QString normalizedProvider = normalizedProviderName(provider); + AIConfig config = m_configStore.providers.value(normalizedProvider, defaultAIConfigForProvider(normalizedProvider)); + config.provider = normalizedProvider; + config.protocol = defaultAIConfigForProvider(normalizedProvider).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.maxTokens = m_maxTokensSpinBox->value(); + config.allowPlainApiKey = m_allowPlainApiKeyCheckBox->isChecked(); + + const QString apiKey = m_apiKeyEdit->text(); + config.apiKey.clear(); + config.apiKeyEncrypted.clear(); + if (!apiKey.isEmpty()) + { + config.apiKey = apiKey; + config.apiKeyStorage = QStringLiteral("runtime"); + } + else + { + config.apiKeyStorage = QStringLiteral("none"); + } + + config.stream = false; + config.temperature = 0.0; + config.maxTokens = qMin(config.maxTokens, 16); + return config; +} + QString SettingsDialog::decryptedApiKey(const AIConfig &config) const { if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty()) @@ -270,3 +478,75 @@ QString SettingsDialog::decryptedApiKey(const AIConfig &config) const return config.apiKey; } + +void SettingsDialog::testConnection() +{ + if (m_aiTestBlocked && m_aiTestBlocked()) + { + QMessageBox::information( + this, + QStringLiteral("测试连接"), + QStringLiteral("当前 AI 对话正在进行,请等待对话结束后再测试连接。")); + return; + } + + if (m_testProvider && m_testProvider->isBusy()) + { + return; + } + + const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString()); + AIConfig config = runtimeConfigFromForm(provider); + QString errorMessage; + if (!AIProviderFactory::prepareRuntimeConfig(config, &errorMessage)) + { + setTestStatus(errorMessage, QStringLiteral("error")); + return; + } + + std::unique_ptr providerInstance = AIProviderFactory::createProvider(config); + if (!providerInstance) + { + setTestStatus(QStringLiteral("当前 Provider 协议暂未接入。"), QStringLiteral("error")); + return; + } + + m_testProvider = std::move(providerInstance); + m_testConnectionButton->setEnabled(false); + setTestStatus(QStringLiteral("正在测试连接..."), QStringLiteral("info")); + + ChatRequest request; + request.messages.append({ + QStringLiteral("user"), + QStringLiteral("请只回复 OK,用于连接测试。") + }); + + QPointer dialog(this); + m_testProvider->sendChatRequest(request, [dialog](const ChatResponse &response) { + if (dialog.isNull()) + { + return; + } + + dialog->m_testConnectionButton->setEnabled(true); + if (response.success) + { + const QString status = response.httpStatus > 0 + ? QStringLiteral("连接成功,HTTP ") + QString::number(response.httpStatus) + : QStringLiteral("连接成功。"); + dialog->setTestStatus(status, QStringLiteral("success")); + return; + } + + dialog->setTestStatus(QStringLiteral("连接失败:") + userVisibleErrorMessage(response), QStringLiteral("error")); + }); +} + +void SettingsDialog::setTestStatus(const QString &message, const QString &state) +{ + m_testStatusLabel->setText(message); + m_testStatusLabel->setToolTip(message); + m_testStatusLabel->setProperty("status", state); + m_testStatusLabel->style()->unpolish(m_testStatusLabel); + m_testStatusLabel->style()->polish(m_testStatusLabel); +} diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index d36589f..323ded5 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -5,16 +5,27 @@ #include +#include +#include + class QCheckBox; class QComboBox; class QDoubleSpinBox; +class QLabel; class QLineEdit; +class QPushButton; class QSpinBox; +class LLMProvider; class SettingsDialog : public QDialog { public: - explicit SettingsDialog(const AIConfigStore &configStore, const AppConfig &appConfig, QWidget *parent = nullptr); + explicit SettingsDialog( + const AIConfigStore &configStore, + const AppConfig &appConfig, + std::function aiTestBlocked, + QWidget *parent = nullptr); + ~SettingsDialog() override; AIConfigStore aiConfigStore() const; AppConfig appConfig() const; @@ -24,7 +35,10 @@ private: void loadProviderConfig(const QString &provider); void switchProvider(const QString &provider); AIConfig configFromForm(const QString &provider) const; + AIConfig runtimeConfigFromForm(const QString &provider) const; QString decryptedApiKey(const AIConfig &config) const; + void testConnection(); + void setTestStatus(const QString &message, const QString &state); QComboBox *m_providerComboBox = nullptr; QLineEdit *m_baseUrlEdit = nullptr; @@ -34,12 +48,17 @@ private: QSpinBox *m_timeoutSpinBox = nullptr; QDoubleSpinBox *m_temperatureSpinBox = nullptr; QSpinBox *m_maxTokensSpinBox = nullptr; + QPushButton *m_testConnectionButton = nullptr; + QLabel *m_testStatusLabel = nullptr; QCheckBox *m_allowPlainApiKeyCheckBox = nullptr; QSpinBox *m_scaleSpinBox = nullptr; QComboBox *m_performanceModeComboBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_enableLazyLoadCheckBox = nullptr; + QComboBox *m_characterComboBox = nullptr; AIConfigStore m_configStore; AppConfig m_appConfig; QString m_currentProvider; + std::function m_aiTestBlocked; + std::unique_ptr m_testProvider; };