diff --git a/CMakeLists.txt b/CMakeLists.txt index d3df0f9..4a060dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,8 @@ qt_add_executable(QtDesktopPet src/ai/AIProviderFactory.cpp src/ai/ConversationManager.h src/ai/ConversationManager.cpp + src/ai/ConversationStore.h + src/ai/ConversationStore.cpp src/ai/LLMProvider.h src/ai/LLMTypes.h src/ai/GoogleGeminiProvider.h diff --git a/src/ai/ConversationManager.cpp b/src/ai/ConversationManager.cpp index da8c702..60e60ae 100644 --- a/src/ai/ConversationManager.cpp +++ b/src/ai/ConversationManager.cpp @@ -28,6 +28,44 @@ QVector ConversationManager::history() const return m_history; } +int ConversationManager::prunedHistoryMessageCount() const +{ + return m_prunedHistoryMessageCount; +} + +void ConversationManager::setHistory(const QVector &history) +{ + m_history.clear(); + for (const ChatMessage &message : history) + { + if (message.role.trimmed().isEmpty() || message.content.trimmed().isEmpty()) + { + continue; + } + + m_history.append(message); + } + + m_prunedHistoryMessageCount = 0; + if (m_history.size() % 2 != 0) + { + m_history.erase(m_history.begin()); + ++m_prunedHistoryMessageCount; + } + pruneHistory(); +} + +void ConversationManager::setRequestContextMessageLimit(int maxMessages) +{ + m_maxRequestContextMessages = qMax(0, maxMessages); +} + +void ConversationManager::setMemoryHistoryMessageLimit(int maxMessages) +{ + m_maxStoredHistoryMessages = qMax(0, maxMessages); + pruneHistory(); +} + bool ConversationManager::setProvider(std::unique_ptr provider) { if (isBusy()) @@ -146,6 +184,7 @@ void ConversationManager::clear() } m_history.clear(); + m_prunedHistoryMessageCount = 0; } ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) const @@ -156,7 +195,7 @@ ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) co request.messages.append({QStringLiteral("system"), m_systemPrompt}); } - const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestHistoryMessages); + const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestContextMessages); for (int index = firstHistoryIndex; index < m_history.size(); ++index) { request.messages.append(m_history.at(index)); @@ -169,4 +208,33 @@ void ConversationManager::appendExchange(const ChatMessage &userMessage, const C { m_history.append(userMessage); m_history.append(assistantMessage); + pruneHistory(); +} + +void ConversationManager::pruneHistory() +{ + const int maxMessages = normalizedStoredHistoryLimit(); + if (m_history.size() <= maxMessages) + { + return; + } + + const int removeCount = m_history.size() - maxMessages; + m_history.erase(m_history.begin(), m_history.begin() + removeCount); + m_prunedHistoryMessageCount += removeCount; +} + +int ConversationManager::normalizedStoredHistoryLimit() const +{ + if (m_maxStoredHistoryMessages <= 0) + { + return 0; + } + + if (m_maxStoredHistoryMessages == 1) + { + return 2; + } + + return m_maxStoredHistoryMessages - (m_maxStoredHistoryMessages % 2); } diff --git a/src/ai/ConversationManager.h b/src/ai/ConversationManager.h index ce97cd1..51443bc 100644 --- a/src/ai/ConversationManager.h +++ b/src/ai/ConversationManager.h @@ -19,6 +19,10 @@ public: bool isBusy() const; bool hasHistory() const; QVector history() const; + int prunedHistoryMessageCount() const; + void setHistory(const QVector &history); + void setRequestContextMessageLimit(int maxMessages); + void setMemoryHistoryMessageLimit(int maxMessages); bool setProvider(std::unique_ptr provider); void sendUserMessage(const QString &message, ResponseCallback callback); void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback); @@ -28,9 +32,13 @@ public: private: ChatRequest buildRequest(const ChatMessage &userMessage) const; void appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage); + void pruneHistory(); + int normalizedStoredHistoryLimit() const; std::unique_ptr m_provider; QVector m_history; QString m_systemPrompt; - int m_maxRequestHistoryMessages = 12; + int m_maxRequestContextMessages = 12; + int m_maxStoredHistoryMessages = 200; + int m_prunedHistoryMessageCount = 0; }; diff --git a/src/ai/ConversationStore.cpp b/src/ai/ConversationStore.cpp new file mode 100644 index 0000000..78f2a19 --- /dev/null +++ b/src/ai/ConversationStore.cpp @@ -0,0 +1,224 @@ +#include "ConversationStore.h" + +#include "../util/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +constexpr int StoreVersion = 1; + +QJsonObject objectFromMessage(const ChatMessage &message) +{ + QJsonObject object; + object.insert(QStringLiteral("role"), message.role); + object.insert(QStringLiteral("content"), message.content); + return object; +} + +ChatMessage messageFromObject(const QJsonObject &object) +{ + return { + object.value(QStringLiteral("role")).toString().trimmed(), + object.value(QStringLiteral("content")).toString() + }; +} + +bool sameMessage(const ChatMessage &left, const ChatMessage &right) +{ + return left.role == right.role && left.content == right.content; +} + +int overlappingMessageCount(const QVector &existingMessages, const QVector &newMessages) +{ + const int maxOverlap = qMin(existingMessages.size(), newMessages.size()); + for (int overlap = maxOverlap; overlap > 0; --overlap) + { + bool matched = true; + const int existingStart = existingMessages.size() - overlap; + for (int index = 0; index < overlap; ++index) + { + if (!sameMessage(existingMessages.at(existingStart + index), newMessages.at(index))) + { + matched = false; + break; + } + } + + if (matched) + { + return overlap; + } + } + + return 0; +} +} + +ConversationStore::ConversationStore(QString filePath) + : m_filePath(std::move(filePath)) +{ +} + +QVector ConversationStore::load(int maxMessages, QString *errorMessage) const +{ + return recentMessages(readMessages(errorMessage), maxMessages); +} + +bool ConversationStore::save(const QVector &messages, int maxMessages) const +{ + const QFileInfo fileInfo(m_filePath); + QDir directory(fileInfo.absolutePath()); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + Logger::warning(QStringLiteral("Unable to create conversation history directory.")); + return false; + } + + QVector mergedMessages = readMessages(); + const int overlap = overlappingMessageCount(mergedMessages, messages); + for (int index = overlap; index < messages.size(); ++index) + { + const ChatMessage &message = messages.at(index); + if (message.role.isEmpty() || message.content.trimmed().isEmpty()) + { + continue; + } + + mergedMessages.append(message); + } + + QJsonArray messageArray; + const QVector storedMessages = recentMessages(mergedMessages, maxMessages); + for (const ChatMessage &message : storedMessages) + { + if (message.role.isEmpty() || message.content.trimmed().isEmpty()) + { + continue; + } + + messageArray.append(objectFromMessage(message)); + } + + QJsonObject root; + root.insert(QStringLiteral("version"), StoreVersion); + root.insert(QStringLiteral("messages"), messageArray); + + QFile file(m_filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + Logger::warning(QStringLiteral("Unable to open conversation history for writing.")); + return false; + } + + const QJsonDocument document(root); + return file.write(document.toJson(QJsonDocument::Indented)) >= 0; +} + +bool ConversationStore::clear() const +{ + if (!QFile::exists(m_filePath)) + { + return true; + } + + if (!QFile::remove(m_filePath)) + { + Logger::warning(QStringLiteral("Unable to remove conversation history.")); + return false; + } + + return true; +} + +QString ConversationStore::filePath() const +{ + return m_filePath; +} + +QVector ConversationStore::readMessages(QString *errorMessage) const +{ + QVector messages; + + QFile file(m_filePath); + if (!file.exists()) + { + return messages; + } + + if (!file.open(QIODevice::ReadOnly)) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("Unable to read conversation history."); + } + Logger::warning(QStringLiteral("Unable to read conversation history.")); + return messages; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + if (errorMessage != nullptr) + { + *errorMessage = QStringLiteral("Conversation history is not valid JSON."); + } + Logger::warning(QStringLiteral("Conversation history is broken; it will be ignored.")); + return messages; + } + + const QJsonArray messageArray = document.object().value(QStringLiteral("messages")).toArray(); + for (const QJsonValue &value : messageArray) + { + if (!value.isObject()) + { + continue; + } + + const ChatMessage message = messageFromObject(value.toObject()); + if (message.role.isEmpty() || message.content.trimmed().isEmpty()) + { + continue; + } + + messages.append(message); + } + + return messages; +} + +QVector ConversationStore::recentMessages(const QVector &messages, int maxMessages) const +{ + int boundedMaxMessages = qMax(0, maxMessages); + if (boundedMaxMessages == 1) + { + boundedMaxMessages = 0; + } + else if (boundedMaxMessages > 1) + { + boundedMaxMessages -= boundedMaxMessages % 2; + } + if (boundedMaxMessages <= 0 || messages.isEmpty()) + { + return {}; + } + + const int firstIndex = qMax(0, messages.size() - boundedMaxMessages); + QVector recent = messages.mid(firstIndex); + if (recent.size() % 2 != 0) + { + recent.erase(recent.begin()); + } + return recent; +} diff --git a/src/ai/ConversationStore.h b/src/ai/ConversationStore.h new file mode 100644 index 0000000..3dd8652 --- /dev/null +++ b/src/ai/ConversationStore.h @@ -0,0 +1,23 @@ +#pragma once + +#include "LLMTypes.h" + +#include +#include + +class ConversationStore +{ +public: + explicit ConversationStore(QString filePath); + + QVector load(int maxMessages, QString *errorMessage = nullptr) const; + bool save(const QVector &messages, int maxMessages) const; + bool clear() const; + QString filePath() const; + +private: + QVector readMessages(QString *errorMessage = nullptr) const; + QVector recentMessages(const QVector &messages, int maxMessages) const; + + QString m_filePath; +}; diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index 85702de..a8399c9 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -12,4 +12,8 @@ struct AppConfig QString performanceMode = QStringLiteral("standard"); bool pauseWhenHidden = true; bool enableLazyLoad = true; + int requestContextMessageLimit = 12; + int memoryHistoryMessageLimit = 200; + bool saveConversationHistory = false; + int savedHistoryMessageLimit = 500; }; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 29b0b47..1409f9c 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -15,6 +15,7 @@ namespace { const QString AppConfigFileName = QStringLiteral("app_config.json"); const QString AIConfigFileName = QStringLiteral("ai_config.json"); +const QString ConversationHistoryFileName = QStringLiteral("conversation_history.json"); QJsonObject windowObjectFromConfig(const AppConfig &config) { @@ -35,6 +36,16 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config) return performance; } +QJsonObject chatObjectFromConfig(const AppConfig &config) +{ + QJsonObject chat; + chat.insert(QStringLiteral("requestContextMessageLimit"), config.requestContextMessageLimit); + chat.insert(QStringLiteral("memoryHistoryMessageLimit"), config.memoryHistoryMessageLimit); + chat.insert(QStringLiteral("saveConversationHistory"), config.saveConversationHistory); + chat.insert(QStringLiteral("savedHistoryMessageLimit"), config.savedHistoryMessageLimit); + return chat; +} + QString normalizedProviderName(const QString &provider) { const QString normalized = provider.trimmed().toLower(); @@ -194,6 +205,27 @@ AppConfig ConfigManager::loadAppConfig() const config.enableLazyLoad = performance.value(QStringLiteral("enableLazyLoad")).toBool(config.enableLazyLoad); } + const QJsonObject chat = root.value(QStringLiteral("chat")).toObject(); + if (chat.contains(QStringLiteral("requestContextMessageLimit"))) + { + config.requestContextMessageLimit = chat.value(QStringLiteral("requestContextMessageLimit")).toInt(config.requestContextMessageLimit); + } + + if (chat.contains(QStringLiteral("memoryHistoryMessageLimit"))) + { + config.memoryHistoryMessageLimit = chat.value(QStringLiteral("memoryHistoryMessageLimit")).toInt(config.memoryHistoryMessageLimit); + } + + if (chat.contains(QStringLiteral("saveConversationHistory"))) + { + config.saveConversationHistory = chat.value(QStringLiteral("saveConversationHistory")).toBool(config.saveConversationHistory); + } + + if (chat.contains(QStringLiteral("savedHistoryMessageLimit"))) + { + config.savedHistoryMessageLimit = chat.value(QStringLiteral("savedHistoryMessageLimit")).toInt(config.savedHistoryMessageLimit); + } + return config; } @@ -280,6 +312,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const QJsonObject root; root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); + root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); QFile file(appConfigPath()); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) @@ -338,6 +371,11 @@ QString ConfigManager::aiConfigPath() const return QDir(configDirectoryPath()).filePath(AIConfigFileName); } +QString ConfigManager::conversationHistoryPath() const +{ + return QDir(configDirectoryPath()).filePath(ConversationHistoryFileName); +} + QString ConfigManager::configDirectoryPath() const { const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); diff --git a/src/config/ConfigManager.h b/src/config/ConfigManager.h index 8246015..2441cb6 100644 --- a/src/config/ConfigManager.h +++ b/src/config/ConfigManager.h @@ -18,6 +18,7 @@ public: bool saveAIConfig(const AIConfig &config) const; QString appConfigPath() const; QString aiConfigPath() const; + QString conversationHistoryPath() const; private: QString configDirectoryPath() const; diff --git a/src/ui/ChatHistoryPanel.cpp b/src/ui/ChatHistoryPanel.cpp index 1f42342..fc40602 100644 --- a/src/ui/ChatHistoryPanel.cpp +++ b/src/ui/ChatHistoryPanel.cpp @@ -149,9 +149,10 @@ ChatHistoryPanel::~ChatHistoryPanel() } } -void ChatHistoryPanel::setMessages(const QVector &messages) +void ChatHistoryPanel::setMessages(const QVector &messages, int prunedMessageCount) { m_messages = messages; + m_prunedMessageCount = qMax(0, prunedMessageCount); updateContent(); } @@ -320,6 +321,16 @@ void ChatHistoryPanel::updateContent() ""); bool hasContent = false; + if (m_prunedMessageCount > 0) + { + hasContent = true; + html += QStringLiteral( + "
" + "较早 %1 条消息已按历史上限自动清理。" + "
") + .arg(m_prunedMessageCount); + } + for (const ChatMessage &message : m_messages) { const QString content = htmlEscapedContent(message.content); diff --git a/src/ui/ChatHistoryPanel.h b/src/ui/ChatHistoryPanel.h index ea03c1b..6f8e121 100644 --- a/src/ui/ChatHistoryPanel.h +++ b/src/ui/ChatHistoryPanel.h @@ -16,7 +16,7 @@ public: explicit ChatHistoryPanel(QWidget *parent = nullptr); ~ChatHistoryPanel() override; - void setMessages(const QVector &messages); + void setMessages(const QVector &messages, int prunedMessageCount = 0); void showAt(const QPoint &anchorPosition); void showNear(const QRect &avoidRect); @@ -34,4 +34,5 @@ private: QTextEdit *m_textEdit; QVector m_messages; + int m_prunedMessageCount = 0; }; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 1c287ac..2d7cff5 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -1,7 +1,8 @@ #include "PetWindow.h" -#include "../ai/ConversationManager.h" #include "../ai/AIProviderFactory.h" +#include "../ai/ConversationManager.h" +#include "../ai/ConversationStore.h" #include "../character/CharacterPackageLoader.h" #include "../config/ConfigManager.h" #include "../util/Logger.h" @@ -49,6 +50,12 @@ constexpr int BaseAnimationTargetSize = 320; constexpr int LowPowerFpsCap = 6; constexpr int ChatFinishedReturnDelayMs = 1500; +int evenBoundedHistoryLimit(int value, int minimum, int maximum) +{ + const int boundedValue = qBound(minimum, value, maximum); + return boundedValue - (boundedValue % 2); +} + AppConfig normalizedAppConfig(AppConfig config) { config.scale = qBound(0.5, config.scale, 2.0); @@ -57,6 +64,9 @@ AppConfig normalizedAppConfig(AppConfig config) { config.performanceMode = QStringLiteral("standard"); } + config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200); + config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000); + config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000); return config; } @@ -84,6 +94,7 @@ PetWindow::PetWindow(QWidget *parent) , m_chatHistoryPanel(std::make_unique(this)) , m_chatInputDialog(std::make_unique(MaxUserMessageLength, this)) , m_conversationManager(std::make_unique()) + , m_conversationStore(std::make_unique(ConfigManager().conversationHistoryPath())) , m_petView(new PetView(this)) , m_dragging(false) , m_alwaysOnTop(true) @@ -129,7 +140,10 @@ PetWindow::PetWindow(QWidget *parent) loadInitialImage(); } -PetWindow::~PetWindow() = default; +PetWindow::~PetWindow() +{ + saveConversationHistoryIfNeeded(); +} void PetWindow::applyAppConfig(const AppConfig &config) { @@ -137,9 +151,12 @@ void PetWindow::applyAppConfig(const AppConfig &config) const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale) || m_appConfig.performanceMode != normalizedConfig.performanceMode || m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; + const bool loadPersistedHistory = !m_appConfig.saveConversationHistory + && normalizedConfig.saveConversationHistory; m_appConfig = normalizedConfig; setAlwaysOnTop(m_appConfig.alwaysOnTop); + configureConversation(loadPersistedHistory); if (m_appConfig.hasWindowPosition && isPointVisibleOnScreen(m_appConfig.windowPosition)) { @@ -213,6 +230,8 @@ void PetWindow::openSettingsDialog() ConfigManager configManager; SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() { return isManualStateSwitchLocked(); + }, [this]() { + clearConversation(); }, this); if (dialog.exec() != QDialog::Accepted) { @@ -394,6 +413,7 @@ bool PetWindow::submitChatMessage(const QString &message) window->finishStreamingChat(); window->m_streamingAssistantText = response.content; window->flushStreamingBubble(true); + window->saveConversationHistoryIfNeeded(); window->refreshChatHistoryPanel(); if (shouldReturnToIdleAfterChat) { @@ -420,6 +440,10 @@ void PetWindow::clearConversation() const bool hadActiveRequest = hasActiveAIRequest(); m_conversationManager->clear(); + if (m_conversationStore && !m_conversationStore->clear()) + { + Logger::warning(QStringLiteral("Failed to clear persisted conversation history.")); + } cancelStreamingChat(); refreshChatHistoryPanel(); showBubbleMessage(hadActiveRequest @@ -461,7 +485,8 @@ bool PetWindow::isManualStateSwitchLocked() const void PetWindow::showConversationHistory() { const QVector history = m_conversationManager ? m_conversationManager->history() : QVector(); - m_chatHistoryPanel->setMessages(history); + const int prunedCount = m_conversationManager ? m_conversationManager->prunedHistoryMessageCount() : 0; + m_chatHistoryPanel->setMessages(history, prunedCount); m_chatHistoryPanel->showNear(frameGeometry()); } @@ -473,7 +498,74 @@ void PetWindow::refreshChatHistoryPanel() } const QVector history = m_conversationManager ? m_conversationManager->history() : QVector(); - m_chatHistoryPanel->setMessages(history); + const int prunedCount = m_conversationManager ? m_conversationManager->prunedHistoryMessageCount() : 0; + m_chatHistoryPanel->setMessages(history, prunedCount); +} + +void PetWindow::configureConversation(bool loadPersistedHistory) +{ + if (!m_conversationManager) + { + return; + } + + m_conversationManager->setRequestContextMessageLimit(m_appConfig.requestContextMessageLimit); + m_conversationManager->setMemoryHistoryMessageLimit(m_appConfig.memoryHistoryMessageLimit); + + if (loadPersistedHistory) + { + loadConversationHistoryIfNeeded(); + } + + saveConversationHistoryIfNeeded(); + refreshChatHistoryPanel(); +} + +void PetWindow::loadConversationHistoryIfNeeded() +{ + if (!m_appConfig.saveConversationHistory || !m_conversationManager || !m_conversationStore) + { + return; + } + + if (m_conversationManager->hasHistory()) + { + return; + } + + QString loadError; + const QVector history = m_conversationStore->load(m_appConfig.savedHistoryMessageLimit, &loadError); + if (!loadError.isEmpty()) + { + Logger::warning(QStringLiteral("Failed to load conversation history: ") + loadError); + } + + if (!history.isEmpty()) + { + m_conversationManager->setHistory(history); + } +} + +void PetWindow::saveConversationHistoryIfNeeded() +{ + if (!m_appConfig.saveConversationHistory || !m_conversationManager || !m_conversationStore) + { + return; + } + + if (!m_conversationManager->hasHistory()) + { + if (!m_conversationStore->clear()) + { + Logger::warning(QStringLiteral("Failed to clear empty conversation history.")); + } + return; + } + + if (!m_conversationStore->save(m_conversationManager->history(), m_appConfig.savedHistoryMessageLimit)) + { + Logger::warning(QStringLiteral("Failed to save conversation history.")); + } } void PetWindow::handleChatStreamDelta(const QString &delta) diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index ae01815..2db6a6f 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -21,6 +21,7 @@ class ChatBubble; class ChatHistoryPanel; class ChatInputDialog; class ConversationManager; +class ConversationStore; class PetView; class PetWindow : public QWidget @@ -56,6 +57,9 @@ private: void cancelActiveAIRequest(); void showConversationHistory(); void refreshChatHistoryPanel(); + void configureConversation(bool loadPersistedHistory); + void loadConversationHistoryIfNeeded(); + void saveConversationHistoryIfNeeded(); void handleChatStreamDelta(const QString &delta); void flushStreamingBubble(bool finalUpdate); void finishStreamingChat(); @@ -83,6 +87,7 @@ private: std::unique_ptr m_chatHistoryPanel; std::unique_ptr m_chatInputDialog; std::unique_ptr m_conversationManager; + std::unique_ptr m_conversationStore; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 8da349f..d879f9e 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -62,6 +62,7 @@ SettingsDialog::SettingsDialog( const AIConfigStore &configStore, const AppConfig &appConfig, std::function aiTestBlocked, + std::function clearConversationHistoryCallback, QWidget *parent) : QDialog(parent) , m_providerComboBox(new QComboBox(this)) @@ -78,10 +79,16 @@ SettingsDialog::SettingsDialog( , m_performanceModeComboBox(new QComboBox(this)) , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) , m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this)) + , m_requestContextMessageLimitSpinBox(new QSpinBox(this)) + , m_memoryHistoryMessageLimitSpinBox(new QSpinBox(this)) + , m_saveConversationHistoryCheckBox(new QCheckBox(QStringLiteral("保存聊天记录到本地"), this)) + , m_savedHistoryMessageLimitSpinBox(new QSpinBox(this)) + , m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this)) , m_characterComboBox(new QComboBox(this)) , m_configStore(configStore) , m_appConfig(appConfig) , m_aiTestBlocked(std::move(aiTestBlocked)) + , m_clearConversationHistory(std::move(clearConversationHistoryCallback)) { setWindowTitle(QStringLiteral("设置")); setModal(true); @@ -127,6 +134,24 @@ SettingsDialog::SettingsDialog( m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden); m_enableLazyLoadCheckBox->setChecked(m_appConfig.enableLazyLoad); + m_requestContextMessageLimitSpinBox->setRange(0, 200); + m_requestContextMessageLimitSpinBox->setValue(qBound(0, m_appConfig.requestContextMessageLimit, 200)); + + m_memoryHistoryMessageLimitSpinBox->setRange(2, 5000); + m_memoryHistoryMessageLimitSpinBox->setSingleStep(2); + m_memoryHistoryMessageLimitSpinBox->setValue(qBound(2, m_appConfig.memoryHistoryMessageLimit, 5000)); + + m_saveConversationHistoryCheckBox->setChecked(m_appConfig.saveConversationHistory); + + m_savedHistoryMessageLimitSpinBox->setRange(2, 10000); + m_savedHistoryMessageLimitSpinBox->setSingleStep(2); + m_savedHistoryMessageLimitSpinBox->setValue(qBound(2, m_appConfig.savedHistoryMessageLimit, 10000)); + m_savedHistoryMessageLimitSpinBox->setEnabled(m_saveConversationHistoryCheckBox->isChecked()); + + m_clearConversationStatusLabel = new QLabel(this); + m_clearConversationStatusLabel->setObjectName(QStringLiteral("HintLabel")); + m_clearConversationStatusLabel->setWordWrap(true); + m_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable()); m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString()); loadProviderConfig(m_currentProvider); @@ -201,6 +226,39 @@ SettingsDialog::SettingsDialog( auto *appPage = new QWidget(this); appPage->setLayout(appPageLayout); + auto *chatTitleLabel = new QLabel(QStringLiteral("聊天记录"), this); + chatTitleLabel->setObjectName(QStringLiteral("PageTitle")); + auto *chatHintLabel = new QLabel(QStringLiteral("聊天记录默认只保存在内存中;开启本地保存后会写入用户配置目录。"), this); + chatHintLabel->setObjectName(QStringLiteral("HintLabel")); + chatHintLabel->setWordWrap(true); + + auto *chatFormLayout = new QFormLayout(); + chatFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + chatFormLayout->setLabelAlignment(Qt::AlignRight); + chatFormLayout->setFormAlignment(Qt::AlignTop); + chatFormLayout->setHorizontalSpacing(18); + chatFormLayout->setVerticalSpacing(12); + chatFormLayout->addRow(QStringLiteral("请求上下文上限"), m_requestContextMessageLimitSpinBox); + chatFormLayout->addRow(QStringLiteral("内存历史上限"), m_memoryHistoryMessageLimitSpinBox); + chatFormLayout->addRow(QString(), m_saveConversationHistoryCheckBox); + chatFormLayout->addRow(QStringLiteral("本地保存上限"), m_savedHistoryMessageLimitSpinBox); + + auto *clearHistoryLayout = new QHBoxLayout(); + clearHistoryLayout->addWidget(m_clearConversationHistoryButton); + clearHistoryLayout->addWidget(m_clearConversationStatusLabel, 1); + + auto *chatPageLayout = new QVBoxLayout(); + chatPageLayout->setContentsMargins(24, 24, 24, 24); + chatPageLayout->setSpacing(16); + chatPageLayout->addWidget(chatTitleLabel); + chatPageLayout->addWidget(chatHintLabel); + chatPageLayout->addLayout(chatFormLayout); + chatPageLayout->addLayout(clearHistoryLayout); + chatPageLayout->addStretch(); + + auto *chatPage = new QWidget(this); + chatPage->setLayout(chatPageLayout); + m_characterComboBox->addItem(QStringLiteral("shiroko"), QStringLiteral("shiroko")); m_characterComboBox->setEnabled(false); m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。")); @@ -235,12 +293,14 @@ SettingsDialog::SettingsDialog( navigationList->setFrameShape(QFrame::NoFrame); navigationList->setSpacing(4); navigationList->addItem(QStringLiteral("AI 配置")); + navigationList->addItem(QStringLiteral("聊天")); navigationList->addItem(QStringLiteral("应用")); navigationList->addItem(QStringLiteral("角色")); auto *pageStack = new QStackedWidget(this); pageStack->setObjectName(QStringLiteral("SettingsPages")); pageStack->addWidget(aiPage); + pageStack->addWidget(chatPage); pageStack->addWidget(appPage); pageStack->addWidget(characterPage); @@ -303,6 +363,10 @@ SettingsDialog::SettingsDialog( connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() { testConnection(); }); + connect(m_saveConversationHistoryCheckBox, &QCheckBox::toggled, m_savedHistoryMessageLimitSpinBox, &QSpinBox::setEnabled); + connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() { + this->clearConversationHistory(); + }); } SettingsDialog::~SettingsDialog() @@ -335,6 +399,10 @@ AppConfig SettingsDialog::appConfig() const } config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->isChecked(); config.enableLazyLoad = m_enableLazyLoadCheckBox->isChecked(); + config.requestContextMessageLimit = m_requestContextMessageLimitSpinBox->value(); + config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value(); + config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked(); + config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value(); return config; } @@ -550,3 +618,22 @@ void SettingsDialog::setTestStatus(const QString &message, const QString &state) m_testStatusLabel->style()->unpolish(m_testStatusLabel); m_testStatusLabel->style()->polish(m_testStatusLabel); } + +void SettingsDialog::clearConversationHistory() +{ + const QMessageBox::StandardButton result = QMessageBox::question( + this, + QStringLiteral("清空聊天记录"), + QStringLiteral("确定要清空当前内存和本地保存的聊天记录吗?")); + if (result != QMessageBox::Yes) + { + return; + } + + if (m_clearConversationHistory) + { + m_clearConversationHistory(); + } + + m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。")); +} diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 323ded5..f166755 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -24,6 +24,7 @@ public: const AIConfigStore &configStore, const AppConfig &appConfig, std::function aiTestBlocked, + std::function clearConversationHistoryCallback, QWidget *parent = nullptr); ~SettingsDialog() override; @@ -39,6 +40,7 @@ private: QString decryptedApiKey(const AIConfig &config) const; void testConnection(); void setTestStatus(const QString &message, const QString &state); + void clearConversationHistory(); QComboBox *m_providerComboBox = nullptr; QLineEdit *m_baseUrlEdit = nullptr; @@ -55,10 +57,17 @@ private: QComboBox *m_performanceModeComboBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_enableLazyLoadCheckBox = nullptr; + QSpinBox *m_requestContextMessageLimitSpinBox = nullptr; + QSpinBox *m_memoryHistoryMessageLimitSpinBox = nullptr; + QCheckBox *m_saveConversationHistoryCheckBox = nullptr; + QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr; + QPushButton *m_clearConversationHistoryButton = nullptr; + QLabel *m_clearConversationStatusLabel = nullptr; QComboBox *m_characterComboBox = nullptr; AIConfigStore m_configStore; AppConfig m_appConfig; QString m_currentProvider; std::function m_aiTestBlocked; + std::function m_clearConversationHistory; std::unique_ptr m_testProvider; };