完善设置页连接测试
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ private:
|
||||
void showPetWindow();
|
||||
void hidePetWindow();
|
||||
void togglePetWindowVisibility();
|
||||
void openSettings();
|
||||
void quitApplication();
|
||||
|
||||
PetWindow *m_petWindow = nullptr;
|
||||
|
||||
+38
-101
@@ -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<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 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<LLMProvider> provider = createProvider(config);
|
||||
std::unique_ptr<LLMProvider> provider = AIProviderFactory::createProvider(config);
|
||||
if (!provider)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+305
-25
@@ -1,5 +1,7 @@
|
||||
#include "SettingsDialog.h"
|
||||
|
||||
#include "../ai/AIProviderFactory.h"
|
||||
#include "../ai/LLMTypes.h"
|
||||
#include "../config/SecretStore.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
@@ -7,16 +9,25 @@
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QList>
|
||||
#include <QListWidget>
|
||||
#include <QMessageBox>
|
||||
#include <QPair>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QTabWidget>
|
||||
#include <QStackedWidget>
|
||||
#include <QStyle>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <utility>
|
||||
|
||||
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<bool()> 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<QPair<QString, QString>> 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<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
@@ -5,16 +5,27 @@
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
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<bool()> 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<bool()> m_aiTestBlocked;
|
||||
std::unique_ptr<LLMProvider> m_testProvider;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user