完善 AI 会话和配置隔离

This commit is contained in:
2026-05-29 11:28:41 +08:00
parent f4c7e4a08b
commit cc517e149d
10 changed files with 561 additions and 90 deletions
+2
View File
@@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
qt_add_executable(QtDesktopPet
main.cpp
src/ai/ConversationManager.h
src/ai/ConversationManager.cpp
src/ai/LLMProvider.h
src/ai/LLMTypes.h
src/ai/OpenAICompatibleProvider.h
+127
View File
@@ -0,0 +1,127 @@
#include "ConversationManager.h"
#include <utility>
ConversationManager::ConversationManager()
: m_systemPrompt(QStringLiteral("你是一个桌面宠物助手。回复要简短、自然,适合显示在桌宠气泡里。"))
{
}
ConversationManager::~ConversationManager()
{
cancel();
}
bool ConversationManager::isBusy() const
{
return m_provider && m_provider->isBusy();
}
bool ConversationManager::hasHistory() const
{
return !m_history.isEmpty();
}
bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
{
if (isBusy())
{
return false;
}
m_provider = std::move(provider);
return true;
}
void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback)
{
const QString content = message.trimmed();
if (content.isEmpty())
{
if (callback)
{
callback({false, {}, QStringLiteral("Message is empty."), 0});
}
return;
}
if (!m_provider)
{
if (callback)
{
callback({false, {}, QStringLiteral("AI provider is not ready."), 0});
}
return;
}
if (isBusy())
{
if (callback)
{
callback({false, {}, QStringLiteral("AI request is already running."), 0});
}
return;
}
const ChatMessage userMessage{QStringLiteral("user"), content};
m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
if (response.success)
{
appendExchange(userMessage, {QStringLiteral("assistant"), response.content});
}
if (callback)
{
callback(response);
}
});
}
void ConversationManager::cancel()
{
if (m_provider)
{
m_provider->cancel();
}
}
void ConversationManager::clear()
{
if (isBusy())
{
cancel();
}
m_history.clear();
}
ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) const
{
ChatRequest request;
if (!m_systemPrompt.trimmed().isEmpty())
{
request.messages.append({QStringLiteral("system"), m_systemPrompt});
}
for (const ChatMessage &message : m_history)
{
request.messages.append(message);
}
request.messages.append(userMessage);
return request;
}
void ConversationManager::appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage)
{
m_history.append(userMessage);
m_history.append(assistantMessage);
trimHistory();
}
void ConversationManager::trimHistory()
{
while (m_history.size() > m_maxHistoryMessages)
{
m_history.removeFirst();
}
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "LLMProvider.h"
#include <QString>
#include <QVector>
#include <memory>
class ConversationManager
{
public:
using ResponseCallback = LLMProvider::ResponseCallback;
ConversationManager();
~ConversationManager();
bool isBusy() const;
bool hasHistory() const;
bool setProvider(std::unique_ptr<LLMProvider> provider);
void sendUserMessage(const QString &message, ResponseCallback callback);
void cancel();
void clear();
private:
ChatRequest buildRequest(const ChatMessage &userMessage) const;
void appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage);
void trimHistory();
std::unique_ptr<LLMProvider> m_provider;
QVector<ChatMessage> m_history;
QString m_systemPrompt;
int m_maxHistoryMessages = 12;
};
+7
View File
@@ -1,5 +1,6 @@
#pragma once
#include <QMap>
#include <QString>
struct AIConfig
@@ -19,4 +20,10 @@ struct AIConfig
int maxTokens = 1024;
};
struct AIConfigStore
{
QString activeProvider = QStringLiteral("custom");
QMap<QString, AIConfig> providers;
};
AIConfig defaultAIConfigForProvider(const QString &provider);
+96 -24
View File
@@ -8,6 +8,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QStandardPaths>
namespace
@@ -34,10 +35,16 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
return performance;
}
QJsonObject objectFromAIConfig(const AIConfig &config)
QString normalizedProviderName(const QString &provider)
{
const QString normalized = provider.trimmed().toLower();
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
}
QJsonObject objectFromAIProviderConfig(const AIConfig &config)
{
QJsonObject root;
root.insert(QStringLiteral("provider"), config.provider);
root.insert(QStringLiteral("provider"), normalizedProviderName(config.provider));
root.insert(QStringLiteral("protocol"), config.protocol);
root.insert(QStringLiteral("baseUrl"), config.baseUrl);
root.insert(QStringLiteral("model"), config.model);
@@ -55,6 +62,57 @@ QJsonObject objectFromAIConfig(const AIConfig &config)
root.insert(QStringLiteral("maxTokens"), config.maxTokens);
return root;
}
QJsonObject objectFromAIConfigStore(const AIConfigStore &store)
{
QJsonObject providers;
for (auto iterator = store.providers.constBegin(); iterator != store.providers.constEnd(); ++iterator)
{
const QString provider = normalizedProviderName(iterator.key());
AIConfig config = iterator.value();
config.provider = provider;
providers.insert(provider, objectFromAIProviderConfig(config));
}
QJsonObject root;
root.insert(QStringLiteral("activeProvider"), normalizedProviderName(store.activeProvider));
root.insert(QStringLiteral("providers"), providers);
return root;
}
AIConfig aiProviderConfigFromObject(const QString &provider, const QJsonObject &root)
{
AIConfig config = defaultAIConfigForProvider(provider);
config.provider = provider;
config.protocol = root.value(QStringLiteral("protocol")).toString(config.protocol);
config.baseUrl = root.value(QStringLiteral("baseUrl")).toString(config.baseUrl);
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);
config.maxTokens = root.value(QStringLiteral("maxTokens")).toInt(config.maxTokens);
return config;
}
AIConfig activeAIProviderConfig(const AIConfigStore &store)
{
const QString activeProvider = normalizedProviderName(store.activeProvider);
const auto iterator = store.providers.constFind(activeProvider);
if (iterator != store.providers.constEnd())
{
return iterator.value();
}
return defaultAIConfigForProvider(activeProvider);
}
}
ConfigManager::ConfigManager() = default;
@@ -124,20 +182,20 @@ AppConfig ConfigManager::loadAppConfig() const
return config;
}
AIConfig ConfigManager::loadAIConfig() const
AIConfigStore ConfigManager::loadAIConfigStore() const
{
AIConfig config;
AIConfigStore store;
QFile file(aiConfigPath());
if (!file.exists())
{
return config;
return store;
}
if (!file.open(QIODevice::ReadOnly))
{
Logger::warning(QStringLiteral("Unable to read AI config."));
return config;
return store;
}
QJsonParseError parseError;
@@ -147,27 +205,30 @@ AIConfig ConfigManager::loadAIConfig() const
file.close();
backupBrokenConfig(aiConfigPath());
Logger::warning(QStringLiteral("AI config is broken; default config will be used."));
return config;
return store;
}
const QJsonObject root = document.object();
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.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"))
store.activeProvider = normalizedProviderName(root.value(QStringLiteral("activeProvider")).toString(store.activeProvider));
const QJsonObject providers = root.value(QStringLiteral("providers")).toObject();
for (auto iterator = providers.constBegin(); iterator != providers.constEnd(); ++iterator)
{
config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey);
if (!iterator.value().isObject())
{
continue;
}
const QString provider = normalizedProviderName(iterator.key());
store.providers.insert(provider, aiProviderConfigFromObject(provider, iterator.value().toObject()));
}
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);
config.maxTokens = root.value(QStringLiteral("maxTokens")).toInt(config.maxTokens);
return config;
return store;
}
AIConfig ConfigManager::loadAIConfig() const
{
return activeAIProviderConfig(loadAIConfigStore());
}
bool ConfigManager::saveAppConfig(const AppConfig &config) const
@@ -195,7 +256,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
}
bool ConfigManager::saveAIConfig(const AIConfig &config) const
bool ConfigManager::saveAIConfigStore(const AIConfigStore &store) const
{
const QString directoryPath = configDirectoryPath();
QDir directory(directoryPath);
@@ -212,10 +273,21 @@ bool ConfigManager::saveAIConfig(const AIConfig &config) const
return false;
}
const QJsonDocument document(objectFromAIConfig(config));
const QJsonDocument document(objectFromAIConfigStore(store));
return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
}
bool ConfigManager::saveAIConfig(const AIConfig &config) const
{
AIConfig normalizedConfig = config;
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
AIConfigStore store = loadAIConfigStore();
store.activeProvider = normalizedConfig.provider;
store.providers.insert(normalizedConfig.provider, normalizedConfig);
return saveAIConfigStore(store);
}
QString ConfigManager::appConfigPath() const
{
return QDir(configDirectoryPath()).filePath(AppConfigFileName);
+2
View File
@@ -11,8 +11,10 @@ public:
ConfigManager();
AppConfig loadAppConfig() const;
AIConfigStore loadAIConfigStore() const;
AIConfig loadAIConfig() const;
bool saveAppConfig(const AppConfig &config) const;
bool saveAIConfigStore(const AIConfigStore &store) const;
bool saveAIConfig(const AIConfig &config) const;
QString appConfigPath() const;
QString aiConfigPath() const;
+196 -31
View File
@@ -1,5 +1,6 @@
#include "PetWindow.h"
#include "../ai/ConversationManager.h"
#include "../ai/OpenAICompatibleProvider.h"
#include "../character/CharacterPackageLoader.h"
#include "../config/ConfigManager.h"
@@ -14,6 +15,7 @@
#include <QCursor>
#include <QDialog>
#include <QGuiApplication>
#include <QInputDialog>
#include <QMenu>
#include <QMouseEvent>
#include <QPixmap>
@@ -38,6 +40,8 @@ QString previewImagePath()
return QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/shiroko/preview.png");
}
constexpr int MaxUserMessageLength = 4000;
bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
{
if (!config.apiKey.trimmed().isEmpty())
@@ -71,11 +75,56 @@ bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage)
return true;
}
bool prepareRuntimeAIConfig(AIConfig &config, QString *errorMessage)
{
if (config.protocol != QStringLiteral("openai-compatible"))
{
*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;
}
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;
}
}
PetWindow::PetWindow(QWidget *parent)
: QWidget(parent)
, m_chatBubble(std::make_unique<ChatBubble>())
, m_conversationManager(std::make_unique<ConversationManager>())
, m_petView(new PetView(this))
, m_dragging(false)
, m_alwaysOnTop(true)
@@ -174,7 +223,15 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸"));
QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本"));
const bool aiRequestRunning = hasActiveAIRequest();
QAction *aiTestAction = menu.addAction(QStringLiteral("AI 测试"));
aiTestAction->setEnabled(!aiRequestRunning);
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
chatAction->setEnabled(!aiRequestRunning);
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消 AI 请求"));
cancelAIAction->setEnabled(aiRequestRunning);
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
QAction *settingsAction = menu.addAction(QStringLiteral("设置"));
addStateTestActions(&menu);
@@ -203,11 +260,23 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
{
startAITest();
}
else if (selectedAction == chatAction)
{
startChat();
}
else if (selectedAction == cancelAIAction)
{
cancelActiveAIRequest();
}
else if (selectedAction == clearConversationAction)
{
clearConversation();
}
else if (selectedAction == settingsAction)
{
ConfigManager configManager;
SettingsDialog dialog(configManager.loadAIConfig(), this);
if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfig(dialog.aiConfig()))
SettingsDialog dialog(configManager.loadAIConfigStore(), this);
if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfigStore(dialog.aiConfigStore()))
{
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
}
@@ -224,53 +293,38 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
void PetWindow::startAITest()
{
if (m_aiProvider && m_aiProvider->isBusy())
if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
return;
}
ConfigManager configManager;
AIConfig config = configManager.loadAIConfig();
if (config.protocol != QStringLiteral("openai-compatible"))
if (m_conversationManager && m_conversationManager->isBusy())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("当前 Provider 协议暂未接入 AI 测试。"));
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
return;
}
ConfigManager configManager;
AIConfig config = configManager.loadAIConfig();
QString errorMessage;
if (!populateRuntimeApiKey(config, &errorMessage))
if (!prepareRuntimeAIConfig(config, &errorMessage))
{
playState(QStringLiteral("error"), false);
showBubbleMessage(errorMessage);
return;
}
if (config.baseUrl.trimmed().isEmpty())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("请先在设置里配置 Base URL。"));
return;
}
if (config.model.trimmed().isEmpty())
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("请先在设置里配置 Model。"));
return;
}
ChatRequest request;
request.messages.append({QStringLiteral("system"), QStringLiteral("你是桌宠的最小连通性测试助手。")});
request.messages.append({QStringLiteral("user"), QStringLiteral("请用一句话回复测试成功。")});
m_aiProvider = std::make_unique<OpenAICompatibleProvider>(config);
m_aiTestProvider = std::make_unique<OpenAICompatibleProvider>(config);
playState(QStringLiteral("think"), false);
showBubbleMessage(QStringLiteral("正在测试 AI 连接..."));
QPointer<PetWindow> window(this);
m_aiProvider->sendChatRequest(request, [window](const ChatResponse &response) {
m_aiTestProvider->sendChatRequest(request, [window](const ChatResponse &response) {
if (window.isNull())
{
return;
@@ -283,22 +337,133 @@ void PetWindow::startAITest()
return;
}
QString message = response.errorMessage.trimmed();
if (message.isEmpty())
window->playState(QStringLiteral("error"), false);
window->showBubbleMessage(QStringLiteral("AI 测试失败:") + userVisibleErrorMessage(response));
});
}
void PetWindow::startChat()
{
if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
showBubbleMessage(QStringLiteral("AI 测试请求正在进行。"));
return;
}
if (!m_conversationManager || m_conversationManager->isBusy())
{
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
return;
}
bool accepted = false;
const QString message = QInputDialog::getMultiLineText(
this,
QStringLiteral("聊天"),
QStringLiteral("输入消息"),
{},
&accepted).trimmed();
if (!accepted || message.isEmpty())
{
return;
}
if (message.size() > MaxUserMessageLength)
{
playState(QStringLiteral("error"), false);
showBubbleMessage(QStringLiteral("消息太长,请控制在 4000 字以内。"));
return;
}
ConfigManager configManager;
AIConfig config = configManager.loadAIConfig();
QString errorMessage;
if (!prepareRuntimeAIConfig(config, &errorMessage))
{
playState(QStringLiteral("error"), false);
showBubbleMessage(errorMessage);
return;
}
if (!m_conversationManager->setProvider(std::make_unique<OpenAICompatibleProvider>(config)))
{
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
return;
}
playState(QStringLiteral("think"), false);
showBubbleMessage(QStringLiteral("正在思考..."));
QPointer<PetWindow> window(this);
m_conversationManager->sendUserMessage(message, [window](const ChatResponse &response) {
if (window.isNull())
{
message = QStringLiteral("未知错误。");
return;
}
if (response.httpStatus > 0)
if (response.success)
{
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral("") + message;
window->playState(QStringLiteral("talk"), false);
window->showBubbleMessage(response.content);
return;
}
window->playState(QStringLiteral("error"), false);
window->showBubbleMessage(QStringLiteral("AI 测试失败:") + message);
window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response));
});
}
void PetWindow::clearConversation()
{
if (!m_conversationManager)
{
return;
}
const bool hadActiveRequest = hasActiveAIRequest();
if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
m_aiTestProvider->cancel();
}
m_conversationManager->clear();
showBubbleMessage(hadActiveRequest
? QStringLiteral("已取消 AI 请求,并清空对话。")
: QStringLiteral("对话已清空。"));
playState(QStringLiteral("idle"), false);
}
void PetWindow::cancelActiveAIRequest()
{
bool canceled = false;
if (m_aiTestProvider && m_aiTestProvider->isBusy())
{
m_aiTestProvider->cancel();
canceled = true;
}
if (m_conversationManager && m_conversationManager->isBusy())
{
m_conversationManager->cancel();
canceled = true;
}
if (!canceled)
{
showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。"));
return;
}
showBubbleMessage(QStringLiteral("AI 请求已取消。"));
playState(QStringLiteral("idle"), false);
}
bool PetWindow::hasActiveAIRequest() const
{
return (m_aiTestProvider && m_aiTestProvider->isBusy())
|| (m_conversationManager && m_conversationManager->isBusy());
}
void PetWindow::mouseMoveEvent(QMouseEvent *event)
{
if (m_dragging && (event->buttons() & Qt::LeftButton))
+7 -1
View File
@@ -17,6 +17,7 @@ class QMenu;
class QMoveEvent;
class QPixmap;
class ChatBubble;
class ConversationManager;
class LLMProvider;
class PetView;
@@ -44,6 +45,10 @@ private:
void buildAnimationClips();
void addStateTestActions(QMenu *menu);
void startAITest();
void startChat();
void clearConversation();
void cancelActiveAIRequest();
bool hasActiveAIRequest() const;
void updateBubblePosition();
QPoint bubbleAnchorPosition() const;
void playState(const QString &stateName, bool centerWindow);
@@ -57,7 +62,8 @@ private:
void setAlwaysOnTop(bool enabled);
std::unique_ptr<ChatBubble> m_chatBubble;
std::unique_ptr<LLMProvider> m_aiProvider;
std::unique_ptr<ConversationManager> m_conversationManager;
std::unique_ptr<LLMProvider> m_aiTestProvider;
PetView *m_petView;
QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer;
+81 -30
View File
@@ -14,7 +14,16 @@
#include <QSpinBox>
#include <QVBoxLayout>
SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
namespace
{
QString normalizedProviderName(const QString &provider)
{
const QString normalized = provider.trimmed().toLower();
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
}
}
SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent)
: QDialog(parent)
, m_providerComboBox(new QComboBox(this))
, m_baseUrlEdit(new QLineEdit(this))
@@ -25,7 +34,7 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
, m_temperatureSpinBox(new QDoubleSpinBox(this))
, m_maxTokensSpinBox(new QSpinBox(this))
, m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this))
, m_initialConfig(config)
, m_configStore(configStore)
{
setWindowTitle(QStringLiteral("设置"));
setModal(true);
@@ -44,39 +53,24 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
m_providerComboBox->addItem(provider.second, provider.first);
}
const int providerIndex = m_providerComboBox->findData(config.provider);
const QString activeProvider = normalizedProviderName(m_configStore.activeProvider);
const int providerIndex = m_providerComboBox->findData(activeProvider);
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());
m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString());
loadProviderConfig(m_currentProvider);
auto *formLayout = new QFormLayout();
formLayout->addRow(QStringLiteral("服务商"), m_providerComboBox);
@@ -105,15 +99,63 @@ SettingsDialog::SettingsDialog(const AIConfig &config, QWidget *parent)
layout->addWidget(buttonBox);
connect(m_providerComboBox, &QComboBox::currentTextChanged, this, [this]() {
applyProviderPreset(m_providerComboBox->currentData().toString());
switchProvider(m_providerComboBox->currentData().toString());
});
}
AIConfig SettingsDialog::aiConfig() const
AIConfigStore SettingsDialog::aiConfigStore() const
{
AIConfig config = m_initialConfig;
config.provider = m_providerComboBox->currentData().toString();
config.protocol = defaultAIConfigForProvider(config.provider).protocol;
AIConfigStore store = m_configStore;
const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString());
store.activeProvider = provider;
store.providers.insert(provider, configFromForm(provider));
return store;
}
void SettingsDialog::cacheCurrentProvider()
{
if (m_currentProvider.isEmpty())
{
return;
}
m_configStore.providers.insert(m_currentProvider, configFromForm(m_currentProvider));
}
void SettingsDialog::loadProviderConfig(const QString &provider)
{
const QString normalizedProvider = normalizedProviderName(provider);
const AIConfig config = m_configStore.providers.value(normalizedProvider, defaultAIConfigForProvider(normalizedProvider));
m_baseUrlEdit->setText(config.baseUrl);
m_apiKeyEdit->setText(decryptedApiKey(config));
m_modelEdit->setText(config.model);
m_pathEdit->setText(config.path);
m_timeoutSpinBox->setValue(config.timeoutMs);
m_temperatureSpinBox->setValue(config.temperature);
m_maxTokensSpinBox->setValue(config.maxTokens);
m_allowPlainApiKeyCheckBox->setChecked(config.allowPlainApiKey);
}
void SettingsDialog::switchProvider(const QString &provider)
{
const QString normalizedProvider = normalizedProviderName(provider);
if (normalizedProvider == m_currentProvider)
{
return;
}
cacheCurrentProvider();
loadProviderConfig(normalizedProvider);
m_currentProvider = normalizedProvider;
}
AIConfig SettingsDialog::configFromForm(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();
@@ -156,9 +198,18 @@ AIConfig SettingsDialog::aiConfig() const
return config;
}
void SettingsDialog::applyProviderPreset(const QString &provider)
QString SettingsDialog::decryptedApiKey(const AIConfig &config) const
{
const AIConfig preset = defaultAIConfigForProvider(provider);
m_baseUrlEdit->setText(preset.baseUrl);
m_pathEdit->setText(preset.path);
if (config.apiKeyStorage == QStringLiteral("windows-dpapi") && !config.apiKeyEncrypted.isEmpty())
{
const SecretStore::Result result = SecretStore::unprotectText(config.apiKeyEncrypted);
if (result.success)
{
return result.value;
}
return {};
}
return config.apiKey;
}
+9 -4
View File
@@ -13,12 +13,16 @@ class QSpinBox;
class SettingsDialog : public QDialog
{
public:
explicit SettingsDialog(const AIConfig &config, QWidget *parent = nullptr);
explicit SettingsDialog(const AIConfigStore &configStore, QWidget *parent = nullptr);
AIConfig aiConfig() const;
AIConfigStore aiConfigStore() const;
private:
void applyProviderPreset(const QString &provider);
void cacheCurrentProvider();
void loadProviderConfig(const QString &provider);
void switchProvider(const QString &provider);
AIConfig configFromForm(const QString &provider) const;
QString decryptedApiKey(const AIConfig &config) const;
QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr;
@@ -29,5 +33,6 @@ private:
QDoubleSpinBox *m_temperatureSpinBox = nullptr;
QSpinBox *m_maxTokensSpinBox = nullptr;
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
AIConfig m_initialConfig;
AIConfigStore m_configStore;
QString m_currentProvider;
};