新增对话历史面板

This commit is contained in:
2026-05-29 16:49:00 +08:00
parent 27d63965f4
commit da803da2de
5 changed files with 412 additions and 38 deletions
+2
View File
@@ -41,6 +41,8 @@ qt_add_executable(QtDesktopPet
src/tray/TrayController.cpp src/tray/TrayController.cpp
src/ui/ChatBubble.h src/ui/ChatBubble.h
src/ui/ChatBubble.cpp src/ui/ChatBubble.cpp
src/ui/ChatHistoryPanel.h
src/ui/ChatHistoryPanel.cpp
src/ui/ChatInputDialog.h src/ui/ChatInputDialog.h
src/ui/ChatInputDialog.cpp src/ui/ChatInputDialog.cpp
src/ui/PetView.h src/ui/PetView.h
+349
View File
@@ -0,0 +1,349 @@
#include "ChatHistoryPanel.h"
#include <QApplication>
#include <QEvent>
#include <QFrame>
#include <QGuiApplication>
#include <QRect>
#include <QScreen>
#include <QScrollBar>
#include <QTextEdit>
#include <QVBoxLayout>
#include <QtGlobal>
#include <limits>
namespace
{
constexpr int PanelWidth = 460;
constexpr int PanelHeight = 360;
constexpr int PanelGap = 16;
constexpr int ScreenMargin = 8;
int boundedCoordinate(int value, int minimum, int maximum)
{
if (maximum < minimum)
{
return minimum;
}
return qBound(minimum, value, maximum);
}
qint64 overlapArea(const QRect &first, const QRect &second)
{
const QRect intersection = first.intersected(second);
if (intersection.isEmpty())
{
return 0;
}
return static_cast<qint64>(intersection.width()) * static_cast<qint64>(intersection.height());
}
qint64 squaredDistance(const QPoint &first, const QPoint &second)
{
const qint64 dx = first.x() - second.x();
const qint64 dy = first.y() - second.y();
return dx * dx + dy * dy;
}
QString roleLabel(const QString &role)
{
if (role == QStringLiteral("user"))
{
return QStringLiteral("");
}
if (role == QStringLiteral("assistant"))
{
return QStringLiteral("桌宠");
}
return role;
}
QString roleColor(const QString &role)
{
if (role == QStringLiteral("user"))
{
return QStringLiteral("#1769aa");
}
if (role == QStringLiteral("assistant"))
{
return QStringLiteral("#3f6b34");
}
return QStringLiteral("#5f6368");
}
QString htmlEscapedContent(QString content)
{
content = content.trimmed().toHtmlEscaped();
content.replace(QStringLiteral("\n"), QStringLiteral("<br>"));
return content;
}
}
ChatHistoryPanel::ChatHistoryPanel(QWidget *parent)
: QDialog(parent)
, m_textEdit(new QTextEdit(this))
{
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
setAttribute(Qt::WA_TranslucentBackground);
setModal(false);
resize(PanelWidth, PanelHeight);
m_textEdit->setReadOnly(true);
m_textEdit->setFrameShape(QFrame::NoFrame);
m_textEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_textEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_textEdit->setLineWrapMode(QTextEdit::WidgetWidth);
m_textEdit->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_textEdit->installEventFilter(this);
m_textEdit->viewport()->installEventFilter(this);
m_textEdit->verticalScrollBar()->installEventFilter(this);
if (qApp != nullptr)
{
qApp->installEventFilter(this);
}
setStyleSheet(QStringLiteral(
"QDialog {"
"background: transparent;"
"}"
"QTextEdit {"
"background: rgba(246, 249, 253, 232);"
"color: #202124;"
"border: 1px solid rgba(32, 33, 36, 46);"
"border-radius: 12px;"
"padding: 14px 16px;"
"font-size: 14px;"
"selection-background-color: rgba(66, 133, 244, 90);"
"}"
"QScrollBar:vertical {"
"background: transparent;"
"width: 9px;"
"margin: 12px 5px 12px 0;"
"}"
"QScrollBar::handle:vertical {"
"background: rgba(32, 33, 36, 88);"
"border-radius: 4px;"
"min-height: 28px;"
"}"
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {"
"height: 0;"
"}"));
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_textEdit);
}
ChatHistoryPanel::~ChatHistoryPanel()
{
if (qApp != nullptr)
{
qApp->removeEventFilter(this);
}
}
void ChatHistoryPanel::setMessages(const QVector<ChatMessage> &messages)
{
m_messages = messages;
updateContent();
}
void ChatHistoryPanel::showAt(const QPoint &anchorPosition)
{
move(constrainedTopLeft(anchorPosition, availableGeometryForPoint(anchorPosition)));
show();
raise();
activateWindow();
m_textEdit->setFocus();
m_textEdit->verticalScrollBar()->setValue(m_textEdit->verticalScrollBar()->maximum());
}
void ChatHistoryPanel::showNear(const QRect &avoidRect)
{
move(smartTopLeftNear(avoidRect));
show();
raise();
activateWindow();
m_textEdit->setFocus();
m_textEdit->verticalScrollBar()->setValue(m_textEdit->verticalScrollBar()->maximum());
}
bool ChatHistoryPanel::event(QEvent *event)
{
if (event->type() == QEvent::WindowDeactivate)
{
hide();
return true;
}
return QDialog::event(event);
}
bool ChatHistoryPanel::eventFilter(QObject *watched, QEvent *event)
{
if (watched == qApp && isVisible() && event->type() == QEvent::ApplicationDeactivate)
{
hide();
return false;
}
if (isVisible() && event->type() == QEvent::MouseButtonPress && !isOwnObject(watched))
{
hide();
return false;
}
return QDialog::eventFilter(watched, event);
}
bool ChatHistoryPanel::isOwnObject(QObject *object) const
{
while (object != nullptr)
{
if (object == this || object == m_textEdit || object == m_textEdit->viewport() || object == m_textEdit->verticalScrollBar())
{
return true;
}
object = object->parent();
}
return false;
}
QRect ChatHistoryPanel::availableGeometryForPoint(const QPoint &point) const
{
const QScreen *screen = QGuiApplication::screenAt(point);
if (screen == nullptr)
{
screen = QGuiApplication::primaryScreen();
}
if (screen == nullptr)
{
return {};
}
return screen->availableGeometry();
}
QRect ChatHistoryPanel::availableGeometryForRect(const QRect &rect) const
{
const QScreen *screen = QGuiApplication::screenAt(rect.center());
if (screen == nullptr)
{
screen = QGuiApplication::screenAt(rect.topLeft());
}
if (screen == nullptr)
{
screen = QGuiApplication::primaryScreen();
}
if (screen == nullptr)
{
return {};
}
return screen->availableGeometry();
}
QPoint ChatHistoryPanel::constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const
{
if (!availableGeometry.isValid())
{
return preferredTopLeft;
}
const int x = boundedCoordinate(
preferredTopLeft.x(),
availableGeometry.left() + ScreenMargin,
availableGeometry.right() - width() - ScreenMargin);
const int y = boundedCoordinate(
preferredTopLeft.y(),
availableGeometry.top() + ScreenMargin,
availableGeometry.bottom() - height() - ScreenMargin);
return QPoint(x, y);
}
QPoint ChatHistoryPanel::smartTopLeftNear(const QRect &avoidRect) const
{
if (!avoidRect.isValid())
{
return constrainedTopLeft(pos(), availableGeometryForPoint(pos()));
}
const QRect availableGeometry = availableGeometryForRect(avoidRect);
const QVector<QPoint> preferredTopLefts = {
QPoint(avoidRect.right() + 1 + PanelGap, avoidRect.top()),
QPoint(avoidRect.left() - PanelGap - width(), avoidRect.top()),
QPoint(avoidRect.center().x() - width() / 2, avoidRect.top() - PanelGap - height()),
QPoint(avoidRect.center().x() - width() / 2, avoidRect.bottom() + 1 + PanelGap),
};
QPoint bestTopLeft = constrainedTopLeft(preferredTopLefts.first(), availableGeometry);
qint64 bestOverlap = std::numeric_limits<qint64>::max();
qint64 bestDistance = std::numeric_limits<qint64>::max();
int bestIndex = preferredTopLefts.size();
for (int index = 0; index < preferredTopLefts.size(); ++index)
{
const QPoint candidateTopLeft = constrainedTopLeft(preferredTopLefts.at(index), availableGeometry);
const QRect candidateRect(candidateTopLeft, size());
const qint64 candidateOverlap = overlapArea(candidateRect, avoidRect);
const qint64 candidateDistance = squaredDistance(candidateTopLeft, preferredTopLefts.at(index));
if (candidateOverlap < bestOverlap
|| (candidateOverlap == bestOverlap && candidateDistance < bestDistance)
|| (candidateOverlap == bestOverlap && candidateDistance == bestDistance && index < bestIndex))
{
bestTopLeft = candidateTopLeft;
bestOverlap = candidateOverlap;
bestDistance = candidateDistance;
bestIndex = index;
}
}
return bestTopLeft;
}
void ChatHistoryPanel::updateContent()
{
QString html = QStringLiteral(
"<html><body style=\"font-family:'Microsoft YaHei','Segoe UI',sans-serif; margin:0;\">");
bool hasContent = false;
for (const ChatMessage &message : m_messages)
{
const QString content = htmlEscapedContent(message.content);
if (content.isEmpty())
{
continue;
}
hasContent = true;
html += QStringLiteral(
"<div style=\"margin:0 0 14px 0;\">"
"<div style=\"font-size:12px; font-weight:600; color:%1; margin-bottom:5px;\">%2</div>"
"<div style=\"font-size:14px; line-height:1.45; color:#202124;\">%3</div>"
"</div>")
.arg(roleColor(message.role), roleLabel(message.role).toHtmlEscaped(), content);
}
if (!hasContent)
{
html += QStringLiteral(
"<div style=\"font-size:14px; line-height:1.45; color:#5f6368;\">暂无对话记录。</div>");
}
html += QStringLiteral("</body></html>");
m_textEdit->setHtml(html);
m_textEdit->verticalScrollBar()->setValue(m_textEdit->verticalScrollBar()->maximum());
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "../ai/LLMTypes.h"
#include <QDialog>
#include <QPoint>
#include <QRect>
#include <QVector>
class QEvent;
class QTextEdit;
class ChatHistoryPanel : public QDialog
{
public:
explicit ChatHistoryPanel(QWidget *parent = nullptr);
~ChatHistoryPanel() override;
void setMessages(const QVector<ChatMessage> &messages);
void showAt(const QPoint &anchorPosition);
void showNear(const QRect &avoidRect);
protected:
bool event(QEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
private:
bool isOwnObject(QObject *object) const;
QRect availableGeometryForPoint(const QPoint &point) const;
QRect availableGeometryForRect(const QRect &rect) const;
QPoint constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const;
QPoint smartTopLeftNear(const QRect &avoidRect) const;
void updateContent();
QTextEdit *m_textEdit;
QVector<ChatMessage> m_messages;
};
+21 -38
View File
@@ -7,6 +7,7 @@
#include "../config/SecretStore.h" #include "../config/SecretStore.h"
#include "../util/Logger.h" #include "../util/Logger.h"
#include "ChatBubble.h" #include "ChatBubble.h"
#include "ChatHistoryPanel.h"
#include "ChatInputDialog.h" #include "ChatInputDialog.h"
#include "PetView.h" #include "PetView.h"
#include "SettingsDialog.h" #include "SettingsDialog.h"
@@ -122,47 +123,12 @@ QString userVisibleErrorMessage(const ChatResponse &response)
return message; return message;
} }
QString roleLabel(const QString &role)
{
if (role == QStringLiteral("user"))
{
return QStringLiteral("");
}
if (role == QStringLiteral("assistant"))
{
return QStringLiteral("桌宠");
}
return role;
}
QString formattedConversationHistory(const QVector<ChatMessage> &history)
{
if (history.isEmpty())
{
return QStringLiteral("暂无对话记录。");
}
QStringList lines;
for (const ChatMessage &message : history)
{
const QString content = message.content.trimmed();
if (content.isEmpty())
{
continue;
}
lines.append(roleLabel(message.role) + QStringLiteral("") + content);
}
return lines.isEmpty() ? QStringLiteral("暂无对话记录。") : lines.join(QStringLiteral("\n\n"));
}
} }
PetWindow::PetWindow(QWidget *parent) PetWindow::PetWindow(QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_chatBubble(std::make_unique<ChatBubble>()) , m_chatBubble(std::make_unique<ChatBubble>())
, 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_petView(new PetView(this)) , m_petView(new PetView(this))
@@ -467,6 +433,7 @@ bool PetWindow::submitChatMessage(const QString &message)
{ {
window->playState(QStringLiteral("talk"), false); window->playState(QStringLiteral("talk"), false);
window->showBubbleMessage(response.content); window->showBubbleMessage(response.content);
window->refreshChatHistoryPanel();
return; return;
} }
@@ -491,6 +458,7 @@ void PetWindow::clearConversation()
} }
m_conversationManager->clear(); m_conversationManager->clear();
refreshChatHistoryPanel();
showBubbleMessage(hadActiveRequest showBubbleMessage(hadActiveRequest
? QStringLiteral("已取消 AI 请求,并清空对话。") ? QStringLiteral("已取消 AI 请求,并清空对话。")
: QStringLiteral("对话已清空。")); : QStringLiteral("对话已清空。"));
@@ -531,8 +499,19 @@ bool PetWindow::hasActiveAIRequest() 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_chatBubble->showMessage(formattedConversationHistory(history), bubbleAnchorPosition(), 30000); m_chatHistoryPanel->setMessages(history);
m_chatBubble->setDismissOnExternalInteraction(true); m_chatHistoryPanel->showNear(frameGeometry());
}
void PetWindow::refreshChatHistoryPanel()
{
if (!m_chatHistoryPanel || !m_chatHistoryPanel->isVisible())
{
return;
}
const QVector<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>();
m_chatHistoryPanel->setMessages(history);
} }
void PetWindow::resetBubbleAutoHideTimer() void PetWindow::resetBubbleAutoHideTimer()
@@ -558,6 +537,10 @@ void PetWindow::hideEvent(QHideEvent *event)
{ {
m_chatInputDialog->hide(); m_chatInputDialog->hide();
} }
if (m_chatHistoryPanel)
{
m_chatHistoryPanel->hide();
}
QWidget::hideEvent(event); QWidget::hideEvent(event);
} }
+3
View File
@@ -18,6 +18,7 @@ class QHideEvent;
class QMoveEvent; class QMoveEvent;
class QPixmap; class QPixmap;
class ChatBubble; class ChatBubble;
class ChatHistoryPanel;
class ChatInputDialog; class ChatInputDialog;
class ConversationManager; class ConversationManager;
class LLMProvider; class LLMProvider;
@@ -54,6 +55,7 @@ private:
void clearConversation(); void clearConversation();
void cancelActiveAIRequest(); void cancelActiveAIRequest();
void showConversationHistory(); void showConversationHistory();
void refreshChatHistoryPanel();
bool hasActiveAIRequest() const; bool hasActiveAIRequest() const;
void resetBubbleAutoHideTimer(); void resetBubbleAutoHideTimer();
QPoint chatInputAnchorPosition() const; QPoint chatInputAnchorPosition() const;
@@ -70,6 +72,7 @@ private:
void setAlwaysOnTop(bool enabled); void setAlwaysOnTop(bool enabled);
std::unique_ptr<ChatBubble> m_chatBubble; std::unique_ptr<ChatBubble> m_chatBubble;
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<LLMProvider> m_aiTestProvider; std::unique_ptr<LLMProvider> m_aiTestProvider;