From cc517e149d6196c79f8bbf71bd873af90f9a4bf9 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Fri, 29 May 2026 11:28:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20AI=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=92=8C=E9=85=8D=E7=BD=AE=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 2 + src/ai/ConversationManager.cpp | 127 ++++++++++++++++++ src/ai/ConversationManager.h | 34 +++++ src/config/AIConfig.h | 7 + src/config/ConfigManager.cpp | 120 +++++++++++++---- src/config/ConfigManager.h | 2 + src/ui/PetWindow.cpp | 227 ++++++++++++++++++++++++++++----- src/ui/PetWindow.h | 8 +- src/ui/SettingsDialog.cpp | 111 +++++++++++----- src/ui/SettingsDialog.h | 13 +- 10 files changed, 561 insertions(+), 90 deletions(-) create mode 100644 src/ai/ConversationManager.cpp create mode 100644 src/ai/ConversationManager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 731f7ef..355570a 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/ConversationManager.h + src/ai/ConversationManager.cpp src/ai/LLMProvider.h src/ai/LLMTypes.h src/ai/OpenAICompatibleProvider.h diff --git a/src/ai/ConversationManager.cpp b/src/ai/ConversationManager.cpp new file mode 100644 index 0000000..737701a --- /dev/null +++ b/src/ai/ConversationManager.cpp @@ -0,0 +1,127 @@ +#include "ConversationManager.h" + +#include + +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 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(); + } +} diff --git a/src/ai/ConversationManager.h b/src/ai/ConversationManager.h new file mode 100644 index 0000000..e2f58fc --- /dev/null +++ b/src/ai/ConversationManager.h @@ -0,0 +1,34 @@ +#pragma once + +#include "LLMProvider.h" + +#include +#include + +#include + +class ConversationManager +{ +public: + using ResponseCallback = LLMProvider::ResponseCallback; + + ConversationManager(); + ~ConversationManager(); + + bool isBusy() const; + bool hasHistory() const; + bool setProvider(std::unique_ptr 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 m_provider; + QVector m_history; + QString m_systemPrompt; + int m_maxHistoryMessages = 12; +}; diff --git a/src/config/AIConfig.h b/src/config/AIConfig.h index ffda690..dfcf9d2 100644 --- a/src/config/AIConfig.h +++ b/src/config/AIConfig.h @@ -1,5 +1,6 @@ #pragma once +#include #include struct AIConfig @@ -19,4 +20,10 @@ struct AIConfig int maxTokens = 1024; }; +struct AIConfigStore +{ + QString activeProvider = QStringLiteral("custom"); + QMap providers; +}; + AIConfig defaultAIConfigForProvider(const QString &provider); diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index a37a211..c9c300e 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include 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); diff --git a/src/config/ConfigManager.h b/src/config/ConfigManager.h index bcb6f94..8246015 100644 --- a/src/config/ConfigManager.h +++ b/src/config/ConfigManager.h @@ -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; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 5e0d367..a25e77c 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -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 #include #include +#include #include #include #include @@ -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()) + , m_conversationManager(std::make_unique()) , 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(config); + m_aiTestProvider = std::make_unique(config); playState(QStringLiteral("think"), false); showBubbleMessage(QStringLiteral("正在测试 AI 连接...")); QPointer 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(config))) + { + showBubbleMessage(QStringLiteral("AI 回复正在进行。")); + return; + } + + playState(QStringLiteral("think"), false); + showBubbleMessage(QStringLiteral("正在思考...")); + + QPointer 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)) diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index cfcc77f..17817ff 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -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 m_chatBubble; - std::unique_ptr m_aiProvider; + std::unique_ptr m_conversationManager; + std::unique_ptr m_aiTestProvider; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 2489527..84fc61b 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -14,7 +14,16 @@ #include #include -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; } diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 3aadc62..90150be 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -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; };