完善设置页连接测试

This commit is contained in:
2026-05-30 03:48:42 +08:00
parent 14f1af4b05
commit 46c4f6092b
10 changed files with 485 additions and 127 deletions
+2
View File
@@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
qt_add_executable(QtDesktopPet qt_add_executable(QtDesktopPet
main.cpp main.cpp
src/ai/AIProviderFactory.h
src/ai/AIProviderFactory.cpp
src/ai/ConversationManager.h src/ai/ConversationManager.h
src/ai/ConversationManager.cpp src/ai/ConversationManager.cpp
src/ai/LLMProvider.h src/ai/LLMProvider.h
+1
View File
@@ -20,6 +20,7 @@ int main(int argc, char *argv[])
window.applyAppConfig(configManager.loadAppConfig()); window.applyAppConfig(configManager.loadAppConfig());
TrayController trayController(&window); TrayController trayController(&window);
window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable());
trayController.show(); trayController.show();
QObject::connect(&app, &QCoreApplication::aboutToQuit, [&configManager, &window]() { QObject::connect(&app, &QCoreApplication::aboutToQuit, [&configManager, &window]() {
+87
View File
@@ -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<LLMProvider> AIProviderFactory::createProvider(const AIConfig &config)
{
if (config.protocol == QStringLiteral("google-generative-language"))
{
return std::make_unique<GoogleGeminiProvider>(config);
}
if (config.protocol == QStringLiteral("openai-compatible"))
{
return std::make_unique<OpenAICompatibleProvider>(config);
}
return nullptr;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "../config/AIConfig.h"
#include "LLMProvider.h"
#include <memory>
class AIProviderFactory
{
public:
static bool prepareRuntimeConfig(AIConfig &config, QString *errorMessage);
static std::unique_ptr<LLMProvider> createProvider(const AIConfig &config);
};
+15
View File
@@ -70,6 +70,11 @@ void TrayController::createMenu()
hidePetWindow(); hidePetWindow();
}); });
QAction *settingsAction = m_menu.addAction(QStringLiteral("设置..."));
QObject::connect(settingsAction, &QAction::triggered, [this]() {
openSettings();
});
m_menu.addSeparator(); m_menu.addSeparator();
QAction *quitAction = m_menu.addAction(QStringLiteral("退出")); QAction *quitAction = m_menu.addAction(QStringLiteral("退出"));
@@ -117,6 +122,16 @@ void TrayController::togglePetWindowVisibility()
showPetWindow(); showPetWindow();
} }
void TrayController::openSettings()
{
if (m_petWindow == nullptr)
{
return;
}
m_petWindow->openSettingsDialog();
}
void TrayController::quitApplication() void TrayController::quitApplication()
{ {
if (m_petWindow != nullptr) if (m_petWindow != nullptr)
+1
View File
@@ -18,6 +18,7 @@ private:
void showPetWindow(); void showPetWindow();
void hidePetWindow(); void hidePetWindow();
void togglePetWindowVisibility(); void togglePetWindowVisibility();
void openSettings();
void quitApplication(); void quitApplication();
PetWindow *m_petWindow = nullptr; PetWindow *m_petWindow = nullptr;
+38 -101
View File
@@ -1,11 +1,9 @@
#include "PetWindow.h" #include "PetWindow.h"
#include "../ai/ConversationManager.h" #include "../ai/ConversationManager.h"
#include "../ai/GoogleGeminiProvider.h" #include "../ai/AIProviderFactory.h"
#include "../ai/OpenAICompatibleProvider.h"
#include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageLoader.h"
#include "../config/ConfigManager.h" #include "../config/ConfigManager.h"
#include "../config/SecretStore.h"
#include "../util/Logger.h" #include "../util/Logger.h"
#include "ChatBubble.h" #include "ChatBubble.h"
#include "ChatHistoryPanel.h" #include "ChatHistoryPanel.h"
@@ -62,85 +60,6 @@ AppConfig normalizedAppConfig(AppConfig config)
return 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<LLMProvider> createProvider(const AIConfig &config)
{
if (config.protocol == QStringLiteral("google-generative-language"))
{
return std::make_unique<GoogleGeminiProvider>(config);
}
if (config.protocol == QStringLiteral("openai-compatible"))
{
return std::make_unique<OpenAICompatibleProvider>(config);
}
return nullptr;
}
QString userVisibleErrorMessage(const ChatResponse &response) QString userVisibleErrorMessage(const ChatResponse &response)
{ {
QString message = response.errorMessage.trimmed(); QString message = response.errorMessage.trimmed();
@@ -289,6 +208,34 @@ void PetWindow::showBubbleMessage(const QString &message)
m_chatBubble->showMessage(message, bubbleAnchorPosition()); 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) void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{ {
resetBubbleAutoHideTimer(); resetBubbleAutoHideTimer();
@@ -307,7 +254,11 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
cancelAIAction->setEnabled(aiRequestRunning); cancelAIAction->setEnabled(aiRequestRunning);
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话")); QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory()); clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
QAction *settingsAction = menu.addAction(QStringLiteral("设置")); QAction *settingsAction = nullptr;
if (m_settingsFallbackInContextMenuEnabled)
{
settingsAction = menu.addAction(QStringLiteral("设置"));
}
addStateTestActions(&menu); addStateTestActions(&menu);
@@ -335,23 +286,9 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{ {
clearConversation(); clearConversation();
} }
else if (selectedAction == settingsAction) else if (settingsAction != nullptr && selectedAction == settingsAction)
{ {
ConfigManager configManager; openSettingsDialog();
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."));
}
}
} }
else if (selectedAction == exitAction) else if (selectedAction == exitAction)
{ {
@@ -402,14 +339,14 @@ bool PetWindow::submitChatMessage(const QString &message)
ConfigManager configManager; ConfigManager configManager;
AIConfig config = configManager.loadAIConfig(); AIConfig config = configManager.loadAIConfig();
QString errorMessage; QString errorMessage;
if (!prepareRuntimeAIConfig(config, &errorMessage)) if (!AIProviderFactory::prepareRuntimeConfig(config, &errorMessage))
{ {
playState(QStringLiteral("error"), false); playState(QStringLiteral("error"), false);
showBubbleMessage(errorMessage); showBubbleMessage(errorMessage);
return false; return false;
} }
std::unique_ptr<LLMProvider> provider = createProvider(config); std::unique_ptr<LLMProvider> provider = AIProviderFactory::createProvider(config);
if (!provider) if (!provider)
{ {
playState(QStringLiteral("error"), false); playState(QStringLiteral("error"), false);
+3
View File
@@ -31,6 +31,8 @@ public:
void applyAppConfig(const AppConfig &config); void applyAppConfig(const AppConfig &config);
AppConfig currentAppConfig() const; AppConfig currentAppConfig() const;
void openSettingsDialog();
void setSettingsFallbackInContextMenuEnabled(bool enabled);
void pauseAnimation(); void pauseAnimation();
void resumeAnimation(); void resumeAnimation();
void showBubbleMessage(const QString &message); void showBubbleMessage(const QString &message);
@@ -98,4 +100,5 @@ private:
bool m_returnToIdleAfterResume = false; bool m_returnToIdleAfterResume = false;
bool m_streamingChatActive = false; bool m_streamingChatActive = false;
bool m_streamingTalkStarted = false; bool m_streamingTalkStarted = false;
bool m_settingsFallbackInContextMenuEnabled = true;
}; };
+305 -25
View File
@@ -1,5 +1,7 @@
#include "SettingsDialog.h" #include "SettingsDialog.h"
#include "../ai/AIProviderFactory.h"
#include "../ai/LLMTypes.h"
#include "../config/SecretStore.h" #include "../config/SecretStore.h"
#include <QCheckBox> #include <QCheckBox>
@@ -7,16 +9,25 @@
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QList> #include <QList>
#include <QListWidget>
#include <QMessageBox>
#include <QPair> #include <QPair>
#include <QPointer>
#include <QPushButton>
#include <QSpinBox> #include <QSpinBox>
#include <QTabWidget> #include <QStackedWidget>
#include <QStyle>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include <QtGlobal> #include <QtGlobal>
#include <utility>
namespace namespace
{ {
QString normalizedProviderName(const QString &provider) QString normalizedProviderName(const QString &provider)
@@ -29,9 +40,29 @@ QString normalizedProviderName(const QString &provider)
return normalized.isEmpty() ? QStringLiteral("custom") : normalized; return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
} }
QString userVisibleErrorMessage(const ChatResponse &response)
{
QString message = response.errorMessage.trimmed();
if (message.isEmpty())
{
message = QStringLiteral("未知错误。");
} }
SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig &appConfig, QWidget *parent) if (response.httpStatus > 0)
{
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral("") + message;
}
return message;
}
}
SettingsDialog::SettingsDialog(
const AIConfigStore &configStore,
const AppConfig &appConfig,
std::function<bool()> aiTestBlocked,
QWidget *parent)
: QDialog(parent) : QDialog(parent)
, m_providerComboBox(new QComboBox(this)) , m_providerComboBox(new QComboBox(this))
, m_baseUrlEdit(new QLineEdit(this)) , m_baseUrlEdit(new QLineEdit(this))
@@ -41,17 +72,20 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig
, m_timeoutSpinBox(new QSpinBox(this)) , m_timeoutSpinBox(new QSpinBox(this))
, m_temperatureSpinBox(new QDoubleSpinBox(this)) , m_temperatureSpinBox(new QDoubleSpinBox(this))
, m_maxTokensSpinBox(new QSpinBox(this)) , m_maxTokensSpinBox(new QSpinBox(this))
, m_testConnectionButton(new QPushButton(QStringLiteral("测试连接"), this))
, m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this)) , m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this))
, m_scaleSpinBox(new QSpinBox(this)) , m_scaleSpinBox(new QSpinBox(this))
, m_performanceModeComboBox(new QComboBox(this)) , m_performanceModeComboBox(new QComboBox(this))
, m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this))
, m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this)) , m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this))
, m_characterComboBox(new QComboBox(this))
, m_configStore(configStore) , m_configStore(configStore)
, m_appConfig(appConfig) , m_appConfig(appConfig)
, m_aiTestBlocked(std::move(aiTestBlocked))
{ {
setWindowTitle(QStringLiteral("设置")); setWindowTitle(QStringLiteral("设置"));
setModal(true); setModal(true);
resize(500, 430); resize(760, 520);
const QList<QPair<QString, QString>> providers = { const QList<QPair<QString, QString>> providers = {
{QStringLiteral("openai"), QStringLiteral("OpenAI")}, {QStringLiteral("openai"), QStringLiteral("OpenAI")},
@@ -97,51 +131,186 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig
m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString()); m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString());
loadProviderConfig(m_currentProvider); loadProviderConfig(m_currentProvider);
auto *formLayout = new QFormLayout(); m_testStatusLabel = new QLabel(QStringLiteral("未测试"), this);
formLayout->addRow(QStringLiteral("服务商"), m_providerComboBox); m_testStatusLabel->setObjectName(QStringLiteral("TestStatusLabel"));
formLayout->addRow(QStringLiteral("Base URL"), m_baseUrlEdit); m_testStatusLabel->setWordWrap(true);
formLayout->addRow(QStringLiteral("API Key"), m_apiKeyEdit);
formLayout->addRow(QStringLiteral("Model"), m_modelEdit); auto *aiTitleLabel = new QLabel(QStringLiteral("AI 配置"), this);
formLayout->addRow(QStringLiteral("Path"), m_pathEdit); aiTitleLabel->setObjectName(QStringLiteral("PageTitle"));
formLayout->addRow(QStringLiteral("Timeout"), m_timeoutSpinBox);
formLayout->addRow(QStringLiteral("Temperature"), m_temperatureSpinBox); auto *aiFormLayout = new QFormLayout();
formLayout->addRow(QStringLiteral("Max Tokens"), m_maxTokensSpinBox); aiFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
formLayout->addRow(QString(), m_allowPlainApiKeyCheckBox); 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); 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(); 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_scaleSpinBox);
appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox); appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox);
appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox); appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox);
appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox); 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); auto *appPage = new QWidget(this);
appPage->setLayout(appFormLayout); appPage->setLayout(appPageLayout);
auto *tabWidget = new QTabWidget(this); m_characterComboBox->addItem(QStringLiteral("shiroko"), QStringLiteral("shiroko"));
tabWidget->addTab(aiPage, QStringLiteral("AI")); m_characterComboBox->setEnabled(false);
tabWidget->addTab(appPage, QStringLiteral("应用")); m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。"));
auto *storageHintLabel = new QLabel(this); auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this);
storageHintLabel->setWordWrap(true); characterTitleLabel->setObjectName(QStringLiteral("PageTitle"));
storageHintLabel->setText(SecretStore::isEncryptionAvailable() auto *characterHintLabel = new QLabel(QStringLiteral("当前版本使用内置角色资源。"), this);
? QStringLiteral("API Key 将使用 Windows DPAPI 加密后保存。") characterHintLabel->setObjectName(QStringLiteral("HintLabel"));
: QStringLiteral("当前平台不支持内置加密。只有勾选确认后才会明文保存 API Key。")); 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); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this);
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this); auto *layout = new QVBoxLayout(this);
layout->addWidget(tabWidget); layout->setContentsMargins(16, 16, 16, 16);
layout->addWidget(storageHintLabel); layout->setSpacing(14);
layout->addLayout(contentLayout, 1);
layout->addWidget(buttonBox); 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]() { connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() {
switchProvider(m_providerComboBox->currentData().toString()); 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 AIConfigStore SettingsDialog::aiConfigStore() const
@@ -202,6 +371,13 @@ void SettingsDialog::switchProvider(const QString &provider)
return; return;
} }
if (m_testProvider && m_testProvider->isBusy())
{
m_testProvider->cancel();
m_testConnectionButton->setEnabled(true);
setTestStatus(QStringLiteral("测试已取消。"), QStringLiteral("info"));
}
cacheCurrentProvider(); cacheCurrentProvider();
loadProviderConfig(normalizedProvider); loadProviderConfig(normalizedProvider);
m_currentProvider = normalizedProvider; m_currentProvider = normalizedProvider;
@@ -255,6 +431,38 @@ AIConfig SettingsDialog::configFromForm(const QString &provider) const
return config; 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 QString SettingsDialog::decryptedApiKey(const AIConfig &config) const
{ {
if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty()) if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty())
@@ -270,3 +478,75 @@ QString SettingsDialog::decryptedApiKey(const AIConfig &config) const
return config.apiKey; 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<LLMProvider> 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<SettingsDialog> 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);
}
+20 -1
View File
@@ -5,16 +5,27 @@
#include <QDialog> #include <QDialog>
#include <functional>
#include <memory>
class QCheckBox; class QCheckBox;
class QComboBox; class QComboBox;
class QDoubleSpinBox; class QDoubleSpinBox;
class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton;
class QSpinBox; class QSpinBox;
class LLMProvider;
class SettingsDialog : public QDialog class SettingsDialog : public QDialog
{ {
public: public:
explicit SettingsDialog(const AIConfigStore &configStore, const AppConfig &appConfig, QWidget *parent = nullptr); explicit SettingsDialog(
const AIConfigStore &configStore,
const AppConfig &appConfig,
std::function<bool()> aiTestBlocked,
QWidget *parent = nullptr);
~SettingsDialog() override;
AIConfigStore aiConfigStore() const; AIConfigStore aiConfigStore() const;
AppConfig appConfig() const; AppConfig appConfig() const;
@@ -24,7 +35,10 @@ private:
void loadProviderConfig(const QString &provider); void loadProviderConfig(const QString &provider);
void switchProvider(const QString &provider); void switchProvider(const QString &provider);
AIConfig configFromForm(const QString &provider) const; AIConfig configFromForm(const QString &provider) const;
AIConfig runtimeConfigFromForm(const QString &provider) const;
QString decryptedApiKey(const AIConfig &config) const; QString decryptedApiKey(const AIConfig &config) const;
void testConnection();
void setTestStatus(const QString &message, const QString &state);
QComboBox *m_providerComboBox = nullptr; QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr; QLineEdit *m_baseUrlEdit = nullptr;
@@ -34,12 +48,17 @@ private:
QSpinBox *m_timeoutSpinBox = nullptr; QSpinBox *m_timeoutSpinBox = nullptr;
QDoubleSpinBox *m_temperatureSpinBox = nullptr; QDoubleSpinBox *m_temperatureSpinBox = nullptr;
QSpinBox *m_maxTokensSpinBox = nullptr; QSpinBox *m_maxTokensSpinBox = nullptr;
QPushButton *m_testConnectionButton = nullptr;
QLabel *m_testStatusLabel = nullptr;
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr; QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
QSpinBox *m_scaleSpinBox = nullptr; QSpinBox *m_scaleSpinBox = nullptr;
QComboBox *m_performanceModeComboBox = nullptr; QComboBox *m_performanceModeComboBox = nullptr;
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
QCheckBox *m_enableLazyLoadCheckBox = nullptr; QCheckBox *m_enableLazyLoadCheckBox = nullptr;
QComboBox *m_characterComboBox = nullptr;
AIConfigStore m_configStore; AIConfigStore m_configStore;
AppConfig m_appConfig; AppConfig m_appConfig;
QString m_currentProvider; QString m_currentProvider;
std::function<bool()> m_aiTestBlocked;
std::unique_ptr<LLMProvider> m_testProvider;
}; };