增加聊天历史配置与持久化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -28,6 +28,44 @@ QVector<ChatMessage> ConversationManager::history() const
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ public:
|
||||
bool isBusy() const;
|
||||
bool hasHistory() 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);
|
||||
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<LLMProvider> m_provider;
|
||||
QVector<ChatMessage> m_history;
|
||||
QString m_systemPrompt;
|
||||
int m_maxRequestHistoryMessages = 12;
|
||||
int m_maxRequestContextMessages = 12;
|
||||
int m_maxStoredHistoryMessages = 200;
|
||||
int m_prunedHistoryMessageCount = 0;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ public:
|
||||
bool saveAIConfig(const AIConfig &config) const;
|
||||
QString appConfigPath() const;
|
||||
QString aiConfigPath() const;
|
||||
QString conversationHistoryPath() const;
|
||||
|
||||
private:
|
||||
QString configDirectoryPath() const;
|
||||
|
||||
@@ -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_prunedMessageCount = qMax(0, prunedMessageCount);
|
||||
updateContent();
|
||||
}
|
||||
|
||||
@@ -320,6 +321,16 @@ void ChatHistoryPanel::updateContent()
|
||||
"<html><body style=\"font-family:'Microsoft YaHei','Segoe UI',sans-serif; margin:0;\">");
|
||||
|
||||
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)
|
||||
{
|
||||
const QString content = htmlEscapedContent(message.content);
|
||||
|
||||
@@ -16,7 +16,7 @@ public:
|
||||
explicit ChatHistoryPanel(QWidget *parent = nullptr);
|
||||
~ChatHistoryPanel() override;
|
||||
|
||||
void setMessages(const QVector<ChatMessage> &messages);
|
||||
void setMessages(const QVector<ChatMessage> &messages, int prunedMessageCount = 0);
|
||||
void showAt(const QPoint &anchorPosition);
|
||||
void showNear(const QRect &avoidRect);
|
||||
|
||||
@@ -34,4 +34,5 @@ private:
|
||||
|
||||
QTextEdit *m_textEdit;
|
||||
QVector<ChatMessage> m_messages;
|
||||
int m_prunedMessageCount = 0;
|
||||
};
|
||||
|
||||
+96
-4
@@ -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<ChatHistoryPanel>(this))
|
||||
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
|
||||
, m_conversationManager(std::make_unique<ConversationManager>())
|
||||
, m_conversationStore(std::make_unique<ConversationStore>(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<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());
|
||||
}
|
||||
|
||||
@@ -473,7 +498,74 @@ void PetWindow::refreshChatHistoryPanel()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<ChatHistoryPanel> m_chatHistoryPanel;
|
||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||
std::unique_ptr<ConversationStore> m_conversationStore;
|
||||
PetView *m_petView;
|
||||
QTimer m_idleBehaviorTimer;
|
||||
QTimer m_behaviorReturnTimer;
|
||||
|
||||
@@ -62,6 +62,7 @@ SettingsDialog::SettingsDialog(
|
||||
const AIConfigStore &configStore,
|
||||
const AppConfig &appConfig,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
std::function<void()> 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("聊天记录已清空。"));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public:
|
||||
const AIConfigStore &configStore,
|
||||
const AppConfig &appConfig,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
std::function<void()> 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<bool()> m_aiTestBlocked;
|
||||
std::function<void()> m_clearConversationHistory;
|
||||
std::unique_ptr<LLMProvider> m_testProvider;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user