增加聊天历史配置与持久化

This commit is contained in:
2026-05-30 12:39:38 +08:00
parent 46c4f6092b
commit 37b43624a7
14 changed files with 581 additions and 8 deletions
+2
View File
@@ -18,6 +18,8 @@ qt_add_executable(QtDesktopPet
src/ai/AIProviderFactory.cpp src/ai/AIProviderFactory.cpp
src/ai/ConversationManager.h src/ai/ConversationManager.h
src/ai/ConversationManager.cpp src/ai/ConversationManager.cpp
src/ai/ConversationStore.h
src/ai/ConversationStore.cpp
src/ai/LLMProvider.h src/ai/LLMProvider.h
src/ai/LLMTypes.h src/ai/LLMTypes.h
src/ai/GoogleGeminiProvider.h src/ai/GoogleGeminiProvider.h
+69 -1
View File
@@ -28,6 +28,44 @@ QVector<ChatMessage> ConversationManager::history() const
return m_history; return m_history;
} }
int ConversationManager::prunedHistoryMessageCount() const
{
return m_prunedHistoryMessageCount;
}
void ConversationManager::setHistory(const QVector<ChatMessage> &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<LLMProvider> provider) bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
{ {
if (isBusy()) if (isBusy())
@@ -146,6 +184,7 @@ void ConversationManager::clear()
} }
m_history.clear(); m_history.clear();
m_prunedHistoryMessageCount = 0;
} }
ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) const 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}); 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) for (int index = firstHistoryIndex; index < m_history.size(); ++index)
{ {
request.messages.append(m_history.at(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(userMessage);
m_history.append(assistantMessage); 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);
} }
+9 -1
View File
@@ -19,6 +19,10 @@ public:
bool isBusy() const; bool isBusy() const;
bool hasHistory() const; bool hasHistory() const;
QVector<ChatMessage> history() const; QVector<ChatMessage> history() const;
int prunedHistoryMessageCount() const;
void setHistory(const QVector<ChatMessage> &history);
void setRequestContextMessageLimit(int maxMessages);
void setMemoryHistoryMessageLimit(int maxMessages);
bool setProvider(std::unique_ptr<LLMProvider> provider); bool setProvider(std::unique_ptr<LLMProvider> provider);
void sendUserMessage(const QString &message, ResponseCallback callback); void sendUserMessage(const QString &message, ResponseCallback callback);
void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback); void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
@@ -28,9 +32,13 @@ public:
private: private:
ChatRequest buildRequest(const ChatMessage &userMessage) const; ChatRequest buildRequest(const ChatMessage &userMessage) const;
void appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage); void appendExchange(const ChatMessage &userMessage, const ChatMessage &assistantMessage);
void pruneHistory();
int normalizedStoredHistoryLimit() const;
std::unique_ptr<LLMProvider> m_provider; std::unique_ptr<LLMProvider> m_provider;
QVector<ChatMessage> m_history; QVector<ChatMessage> m_history;
QString m_systemPrompt; QString m_systemPrompt;
int m_maxRequestHistoryMessages = 12; int m_maxRequestContextMessages = 12;
int m_maxStoredHistoryMessages = 200;
int m_prunedHistoryMessageCount = 0;
}; };
+224
View File
@@ -0,0 +1,224 @@
#include "ConversationStore.h"
#include "../util/Logger.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QtGlobal>
#include <utility>
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<ChatMessage> &existingMessages, const QVector<ChatMessage> &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<ChatMessage> ConversationStore::load(int maxMessages, QString *errorMessage) const
{
return recentMessages(readMessages(errorMessage), maxMessages);
}
bool ConversationStore::save(const QVector<ChatMessage> &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<ChatMessage> 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<ChatMessage> 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<ChatMessage> ConversationStore::readMessages(QString *errorMessage) const
{
QVector<ChatMessage> 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<ChatMessage> ConversationStore::recentMessages(const QVector<ChatMessage> &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<ChatMessage> recent = messages.mid(firstIndex);
if (recent.size() % 2 != 0)
{
recent.erase(recent.begin());
}
return recent;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "LLMTypes.h"
#include <QString>
#include <QVector>
class ConversationStore
{
public:
explicit ConversationStore(QString filePath);
QVector<ChatMessage> load(int maxMessages, QString *errorMessage = nullptr) const;
bool save(const QVector<ChatMessage> &messages, int maxMessages) const;
bool clear() const;
QString filePath() const;
private:
QVector<ChatMessage> readMessages(QString *errorMessage = nullptr) const;
QVector<ChatMessage> recentMessages(const QVector<ChatMessage> &messages, int maxMessages) const;
QString m_filePath;
};
+4
View File
@@ -12,4 +12,8 @@ struct AppConfig
QString performanceMode = QStringLiteral("standard"); QString performanceMode = QStringLiteral("standard");
bool pauseWhenHidden = true; bool pauseWhenHidden = true;
bool enableLazyLoad = true; bool enableLazyLoad = true;
int requestContextMessageLimit = 12;
int memoryHistoryMessageLimit = 200;
bool saveConversationHistory = false;
int savedHistoryMessageLimit = 500;
}; };
+38
View File
@@ -15,6 +15,7 @@ namespace
{ {
const QString AppConfigFileName = QStringLiteral("app_config.json"); const QString AppConfigFileName = QStringLiteral("app_config.json");
const QString AIConfigFileName = QStringLiteral("ai_config.json"); const QString AIConfigFileName = QStringLiteral("ai_config.json");
const QString ConversationHistoryFileName = QStringLiteral("conversation_history.json");
QJsonObject windowObjectFromConfig(const AppConfig &config) QJsonObject windowObjectFromConfig(const AppConfig &config)
{ {
@@ -35,6 +36,16 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
return performance; 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) QString normalizedProviderName(const QString &provider)
{ {
const QString normalized = provider.trimmed().toLower(); const QString normalized = provider.trimmed().toLower();
@@ -194,6 +205,27 @@ AppConfig ConfigManager::loadAppConfig() const
config.enableLazyLoad = performance.value(QStringLiteral("enableLazyLoad")).toBool(config.enableLazyLoad); 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; return config;
} }
@@ -280,6 +312,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
QJsonObject root; QJsonObject root;
root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
QFile file(appConfigPath()); QFile file(appConfigPath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
@@ -338,6 +371,11 @@ QString ConfigManager::aiConfigPath() const
return QDir(configDirectoryPath()).filePath(AIConfigFileName); return QDir(configDirectoryPath()).filePath(AIConfigFileName);
} }
QString ConfigManager::conversationHistoryPath() const
{
return QDir(configDirectoryPath()).filePath(ConversationHistoryFileName);
}
QString ConfigManager::configDirectoryPath() const QString ConfigManager::configDirectoryPath() const
{ {
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
+1
View File
@@ -18,6 +18,7 @@ public:
bool saveAIConfig(const AIConfig &config) const; bool saveAIConfig(const AIConfig &config) const;
QString appConfigPath() const; QString appConfigPath() const;
QString aiConfigPath() const; QString aiConfigPath() const;
QString conversationHistoryPath() const;
private: private:
QString configDirectoryPath() const; QString configDirectoryPath() const;
+12 -1
View File
@@ -149,9 +149,10 @@ ChatHistoryPanel::~ChatHistoryPanel()
} }
} }
void ChatHistoryPanel::setMessages(const QVector<ChatMessage> &messages) void ChatHistoryPanel::setMessages(const QVector<ChatMessage> &messages, int prunedMessageCount)
{ {
m_messages = messages; m_messages = messages;
m_prunedMessageCount = qMax(0, prunedMessageCount);
updateContent(); updateContent();
} }
@@ -320,6 +321,16 @@ void ChatHistoryPanel::updateContent()
"<html><body style=\"font-family:'Microsoft YaHei','Segoe UI',sans-serif; margin:0;\">"); "<html><body style=\"font-family:'Microsoft YaHei','Segoe UI',sans-serif; margin:0;\">");
bool hasContent = false; bool hasContent = false;
if (m_prunedMessageCount > 0)
{
hasContent = true;
html += QStringLiteral(
"<div style=\"margin:0 0 14px 0; padding:8px 10px; border-radius:8px; background:#eef4ff; color:#475467; font-size:13px;\">"
"较早 %1 条消息已按历史上限自动清理。"
"</div>")
.arg(m_prunedMessageCount);
}
for (const ChatMessage &message : m_messages) for (const ChatMessage &message : m_messages)
{ {
const QString content = htmlEscapedContent(message.content); const QString content = htmlEscapedContent(message.content);
+2 -1
View File
@@ -16,7 +16,7 @@ public:
explicit ChatHistoryPanel(QWidget *parent = nullptr); explicit ChatHistoryPanel(QWidget *parent = nullptr);
~ChatHistoryPanel() override; ~ChatHistoryPanel() override;
void setMessages(const QVector<ChatMessage> &messages); void setMessages(const QVector<ChatMessage> &messages, int prunedMessageCount = 0);
void showAt(const QPoint &anchorPosition); void showAt(const QPoint &anchorPosition);
void showNear(const QRect &avoidRect); void showNear(const QRect &avoidRect);
@@ -34,4 +34,5 @@ private:
QTextEdit *m_textEdit; QTextEdit *m_textEdit;
QVector<ChatMessage> m_messages; QVector<ChatMessage> m_messages;
int m_prunedMessageCount = 0;
}; };
+96 -4
View File
@@ -1,7 +1,8 @@
#include "PetWindow.h" #include "PetWindow.h"
#include "../ai/ConversationManager.h"
#include "../ai/AIProviderFactory.h" #include "../ai/AIProviderFactory.h"
#include "../ai/ConversationManager.h"
#include "../ai/ConversationStore.h"
#include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageLoader.h"
#include "../config/ConfigManager.h" #include "../config/ConfigManager.h"
#include "../util/Logger.h" #include "../util/Logger.h"
@@ -49,6 +50,12 @@ constexpr int BaseAnimationTargetSize = 320;
constexpr int LowPowerFpsCap = 6; constexpr int LowPowerFpsCap = 6;
constexpr int ChatFinishedReturnDelayMs = 1500; 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) AppConfig normalizedAppConfig(AppConfig config)
{ {
config.scale = qBound(0.5, config.scale, 2.0); config.scale = qBound(0.5, config.scale, 2.0);
@@ -57,6 +64,9 @@ AppConfig normalizedAppConfig(AppConfig config)
{ {
config.performanceMode = QStringLiteral("standard"); 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; return config;
} }
@@ -84,6 +94,7 @@ PetWindow::PetWindow(QWidget *parent)
, m_chatHistoryPanel(std::make_unique<ChatHistoryPanel>(this)) , m_chatHistoryPanel(std::make_unique<ChatHistoryPanel>(this))
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this)) , m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
, m_conversationManager(std::make_unique<ConversationManager>()) , m_conversationManager(std::make_unique<ConversationManager>())
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
, m_petView(new PetView(this)) , m_petView(new PetView(this))
, m_dragging(false) , m_dragging(false)
, m_alwaysOnTop(true) , m_alwaysOnTop(true)
@@ -129,7 +140,10 @@ PetWindow::PetWindow(QWidget *parent)
loadInitialImage(); loadInitialImage();
} }
PetWindow::~PetWindow() = default; PetWindow::~PetWindow()
{
saveConversationHistoryIfNeeded();
}
void PetWindow::applyAppConfig(const AppConfig &config) 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) const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|| m_appConfig.performanceMode != normalizedConfig.performanceMode || m_appConfig.performanceMode != normalizedConfig.performanceMode
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; || m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad;
const bool loadPersistedHistory = !m_appConfig.saveConversationHistory
&& normalizedConfig.saveConversationHistory;
m_appConfig = normalizedConfig; m_appConfig = normalizedConfig;
setAlwaysOnTop(m_appConfig.alwaysOnTop); setAlwaysOnTop(m_appConfig.alwaysOnTop);
configureConversation(loadPersistedHistory);
if (m_appConfig.hasWindowPosition && isPointVisibleOnScreen(m_appConfig.windowPosition)) if (m_appConfig.hasWindowPosition && isPointVisibleOnScreen(m_appConfig.windowPosition))
{ {
@@ -213,6 +230,8 @@ void PetWindow::openSettingsDialog()
ConfigManager configManager; ConfigManager configManager;
SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() { SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() {
return isManualStateSwitchLocked(); return isManualStateSwitchLocked();
}, [this]() {
clearConversation();
}, this); }, this);
if (dialog.exec() != QDialog::Accepted) if (dialog.exec() != QDialog::Accepted)
{ {
@@ -394,6 +413,7 @@ bool PetWindow::submitChatMessage(const QString &message)
window->finishStreamingChat(); window->finishStreamingChat();
window->m_streamingAssistantText = response.content; window->m_streamingAssistantText = response.content;
window->flushStreamingBubble(true); window->flushStreamingBubble(true);
window->saveConversationHistoryIfNeeded();
window->refreshChatHistoryPanel(); window->refreshChatHistoryPanel();
if (shouldReturnToIdleAfterChat) if (shouldReturnToIdleAfterChat)
{ {
@@ -420,6 +440,10 @@ void PetWindow::clearConversation()
const bool hadActiveRequest = hasActiveAIRequest(); const bool hadActiveRequest = hasActiveAIRequest();
m_conversationManager->clear(); m_conversationManager->clear();
if (m_conversationStore && !m_conversationStore->clear())
{
Logger::warning(QStringLiteral("Failed to clear persisted conversation history."));
}
cancelStreamingChat(); cancelStreamingChat();
refreshChatHistoryPanel(); refreshChatHistoryPanel();
showBubbleMessage(hadActiveRequest showBubbleMessage(hadActiveRequest
@@ -461,7 +485,8 @@ bool PetWindow::isManualStateSwitchLocked() const
void PetWindow::showConversationHistory() void PetWindow::showConversationHistory()
{ {
const QVector<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>(); const QVector<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>();
m_chatHistoryPanel->setMessages(history); const int prunedCount = m_conversationManager ? m_conversationManager->prunedHistoryMessageCount() : 0;
m_chatHistoryPanel->setMessages(history, prunedCount);
m_chatHistoryPanel->showNear(frameGeometry()); m_chatHistoryPanel->showNear(frameGeometry());
} }
@@ -473,7 +498,74 @@ void PetWindow::refreshChatHistoryPanel()
} }
const QVector<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>(); const QVector<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>();
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<ChatMessage> 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) void PetWindow::handleChatStreamDelta(const QString &delta)
+5
View File
@@ -21,6 +21,7 @@ class ChatBubble;
class ChatHistoryPanel; class ChatHistoryPanel;
class ChatInputDialog; class ChatInputDialog;
class ConversationManager; class ConversationManager;
class ConversationStore;
class PetView; class PetView;
class PetWindow : public QWidget class PetWindow : public QWidget
@@ -56,6 +57,9 @@ private:
void cancelActiveAIRequest(); void cancelActiveAIRequest();
void showConversationHistory(); void showConversationHistory();
void refreshChatHistoryPanel(); void refreshChatHistoryPanel();
void configureConversation(bool loadPersistedHistory);
void loadConversationHistoryIfNeeded();
void saveConversationHistoryIfNeeded();
void handleChatStreamDelta(const QString &delta); void handleChatStreamDelta(const QString &delta);
void flushStreamingBubble(bool finalUpdate); void flushStreamingBubble(bool finalUpdate);
void finishStreamingChat(); void finishStreamingChat();
@@ -83,6 +87,7 @@ private:
std::unique_ptr<ChatHistoryPanel> m_chatHistoryPanel; std::unique_ptr<ChatHistoryPanel> m_chatHistoryPanel;
std::unique_ptr<ChatInputDialog> m_chatInputDialog; std::unique_ptr<ChatInputDialog> m_chatInputDialog;
std::unique_ptr<ConversationManager> m_conversationManager; std::unique_ptr<ConversationManager> m_conversationManager;
std::unique_ptr<ConversationStore> m_conversationStore;
PetView *m_petView; PetView *m_petView;
QTimer m_idleBehaviorTimer; QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer; QTimer m_behaviorReturnTimer;
+87
View File
@@ -62,6 +62,7 @@ SettingsDialog::SettingsDialog(
const AIConfigStore &configStore, const AIConfigStore &configStore,
const AppConfig &appConfig, const AppConfig &appConfig,
std::function<bool()> aiTestBlocked, std::function<bool()> aiTestBlocked,
std::function<void()> clearConversationHistoryCallback,
QWidget *parent) QWidget *parent)
: QDialog(parent) : QDialog(parent)
, m_providerComboBox(new QComboBox(this)) , m_providerComboBox(new QComboBox(this))
@@ -78,10 +79,16 @@ SettingsDialog::SettingsDialog(
, m_performanceModeComboBox(new QComboBox(this)) , m_performanceModeComboBox(new QComboBox(this))
, m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this))
, m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this)) , m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this))
, m_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_characterComboBox(new QComboBox(this))
, m_configStore(configStore) , m_configStore(configStore)
, m_appConfig(appConfig) , m_appConfig(appConfig)
, m_aiTestBlocked(std::move(aiTestBlocked)) , m_aiTestBlocked(std::move(aiTestBlocked))
, m_clearConversationHistory(std::move(clearConversationHistoryCallback))
{ {
setWindowTitle(QStringLiteral("设置")); setWindowTitle(QStringLiteral("设置"));
setModal(true); setModal(true);
@@ -127,6 +134,24 @@ SettingsDialog::SettingsDialog(
m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden); m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden);
m_enableLazyLoadCheckBox->setChecked(m_appConfig.enableLazyLoad); 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_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable());
m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString()); m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString());
loadProviderConfig(m_currentProvider); loadProviderConfig(m_currentProvider);
@@ -201,6 +226,39 @@ SettingsDialog::SettingsDialog(
auto *appPage = new QWidget(this); auto *appPage = new QWidget(this);
appPage->setLayout(appPageLayout); 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->addItem(QStringLiteral("shiroko"), QStringLiteral("shiroko"));
m_characterComboBox->setEnabled(false); m_characterComboBox->setEnabled(false);
m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。")); m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。"));
@@ -235,12 +293,14 @@ SettingsDialog::SettingsDialog(
navigationList->setFrameShape(QFrame::NoFrame); navigationList->setFrameShape(QFrame::NoFrame);
navigationList->setSpacing(4); navigationList->setSpacing(4);
navigationList->addItem(QStringLiteral("AI 配置")); navigationList->addItem(QStringLiteral("AI 配置"));
navigationList->addItem(QStringLiteral("聊天"));
navigationList->addItem(QStringLiteral("应用")); navigationList->addItem(QStringLiteral("应用"));
navigationList->addItem(QStringLiteral("角色")); navigationList->addItem(QStringLiteral("角色"));
auto *pageStack = new QStackedWidget(this); auto *pageStack = new QStackedWidget(this);
pageStack->setObjectName(QStringLiteral("SettingsPages")); pageStack->setObjectName(QStringLiteral("SettingsPages"));
pageStack->addWidget(aiPage); pageStack->addWidget(aiPage);
pageStack->addWidget(chatPage);
pageStack->addWidget(appPage); pageStack->addWidget(appPage);
pageStack->addWidget(characterPage); pageStack->addWidget(characterPage);
@@ -303,6 +363,10 @@ SettingsDialog::SettingsDialog(
connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() { connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() {
testConnection(); testConnection();
}); });
connect(m_saveConversationHistoryCheckBox, &QCheckBox::toggled, m_savedHistoryMessageLimitSpinBox, &QSpinBox::setEnabled);
connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() {
this->clearConversationHistory();
});
} }
SettingsDialog::~SettingsDialog() SettingsDialog::~SettingsDialog()
@@ -335,6 +399,10 @@ AppConfig SettingsDialog::appConfig() const
} }
config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->isChecked(); config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->isChecked();
config.enableLazyLoad = m_enableLazyLoadCheckBox->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; return config;
} }
@@ -550,3 +618,22 @@ void SettingsDialog::setTestStatus(const QString &message, const QString &state)
m_testStatusLabel->style()->unpolish(m_testStatusLabel); m_testStatusLabel->style()->unpolish(m_testStatusLabel);
m_testStatusLabel->style()->polish(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("聊天记录已清空。"));
}
+9
View File
@@ -24,6 +24,7 @@ public:
const AIConfigStore &configStore, const AIConfigStore &configStore,
const AppConfig &appConfig, const AppConfig &appConfig,
std::function<bool()> aiTestBlocked, std::function<bool()> aiTestBlocked,
std::function<void()> clearConversationHistoryCallback,
QWidget *parent = nullptr); QWidget *parent = nullptr);
~SettingsDialog() override; ~SettingsDialog() override;
@@ -39,6 +40,7 @@ private:
QString decryptedApiKey(const AIConfig &config) const; QString decryptedApiKey(const AIConfig &config) const;
void testConnection(); void testConnection();
void setTestStatus(const QString &message, const QString &state); void setTestStatus(const QString &message, const QString &state);
void clearConversationHistory();
QComboBox *m_providerComboBox = nullptr; QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr; QLineEdit *m_baseUrlEdit = nullptr;
@@ -55,10 +57,17 @@ private:
QComboBox *m_performanceModeComboBox = nullptr; QComboBox *m_performanceModeComboBox = nullptr;
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
QCheckBox *m_enableLazyLoadCheckBox = 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; QComboBox *m_characterComboBox = nullptr;
AIConfigStore m_configStore; AIConfigStore m_configStore;
AppConfig m_appConfig; AppConfig m_appConfig;
QString m_currentProvider; QString m_currentProvider;
std::function<bool()> m_aiTestBlocked; std::function<bool()> m_aiTestBlocked;
std::function<void()> m_clearConversationHistory;
std::unique_ptr<LLMProvider> m_testProvider; std::unique_ptr<LLMProvider> m_testProvider;
}; };