diff --git a/CMakeLists.txt b/CMakeLists.txt index 97daf8f..6f2ebc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,8 @@ qt_add_executable(QtDesktopPet src/tray/TrayController.cpp src/ui/ChatBubble.h src/ui/ChatBubble.cpp + src/ui/ChatHistoryPanel.h + src/ui/ChatHistoryPanel.cpp src/ui/ChatInputDialog.h src/ui/ChatInputDialog.cpp src/ui/PetView.h diff --git a/src/ui/ChatHistoryPanel.cpp b/src/ui/ChatHistoryPanel.cpp new file mode 100644 index 0000000..1f42342 --- /dev/null +++ b/src/ui/ChatHistoryPanel.cpp @@ -0,0 +1,349 @@ +#include "ChatHistoryPanel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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(intersection.width()) * static_cast(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("
")); + 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 &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 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::max(); + qint64 bestDistance = std::numeric_limits::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( + ""); + + bool hasContent = false; + for (const ChatMessage &message : m_messages) + { + const QString content = htmlEscapedContent(message.content); + if (content.isEmpty()) + { + continue; + } + + hasContent = true; + html += QStringLiteral( + "
" + "
%2
" + "
%3
" + "
") + .arg(roleColor(message.role), roleLabel(message.role).toHtmlEscaped(), content); + } + + if (!hasContent) + { + html += QStringLiteral( + "
暂无对话记录。
"); + } + + html += QStringLiteral(""); + m_textEdit->setHtml(html); + m_textEdit->verticalScrollBar()->setValue(m_textEdit->verticalScrollBar()->maximum()); +} diff --git a/src/ui/ChatHistoryPanel.h b/src/ui/ChatHistoryPanel.h new file mode 100644 index 0000000..ea03c1b --- /dev/null +++ b/src/ui/ChatHistoryPanel.h @@ -0,0 +1,37 @@ +#pragma once + +#include "../ai/LLMTypes.h" + +#include +#include +#include +#include + +class QEvent; +class QTextEdit; + +class ChatHistoryPanel : public QDialog +{ +public: + explicit ChatHistoryPanel(QWidget *parent = nullptr); + ~ChatHistoryPanel() override; + + void setMessages(const QVector &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 m_messages; +}; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index cca7729..dec42c4 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -7,6 +7,7 @@ #include "../config/SecretStore.h" #include "../util/Logger.h" #include "ChatBubble.h" +#include "ChatHistoryPanel.h" #include "ChatInputDialog.h" #include "PetView.h" #include "SettingsDialog.h" @@ -122,47 +123,12 @@ QString userVisibleErrorMessage(const ChatResponse &response) 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 &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) : QWidget(parent) , m_chatBubble(std::make_unique()) + , m_chatHistoryPanel(std::make_unique(this)) , m_chatInputDialog(std::make_unique(MaxUserMessageLength, this)) , m_conversationManager(std::make_unique()) , m_petView(new PetView(this)) @@ -467,6 +433,7 @@ bool PetWindow::submitChatMessage(const QString &message) { window->playState(QStringLiteral("talk"), false); window->showBubbleMessage(response.content); + window->refreshChatHistoryPanel(); return; } @@ -491,6 +458,7 @@ void PetWindow::clearConversation() } m_conversationManager->clear(); + refreshChatHistoryPanel(); showBubbleMessage(hadActiveRequest ? QStringLiteral("已取消 AI 请求,并清空对话。") : QStringLiteral("对话已清空。")); @@ -531,8 +499,19 @@ bool PetWindow::hasActiveAIRequest() const void PetWindow::showConversationHistory() { const QVector history = m_conversationManager ? m_conversationManager->history() : QVector(); - m_chatBubble->showMessage(formattedConversationHistory(history), bubbleAnchorPosition(), 30000); - m_chatBubble->setDismissOnExternalInteraction(true); + m_chatHistoryPanel->setMessages(history); + m_chatHistoryPanel->showNear(frameGeometry()); +} + +void PetWindow::refreshChatHistoryPanel() +{ + if (!m_chatHistoryPanel || !m_chatHistoryPanel->isVisible()) + { + return; + } + + const QVector history = m_conversationManager ? m_conversationManager->history() : QVector(); + m_chatHistoryPanel->setMessages(history); } void PetWindow::resetBubbleAutoHideTimer() @@ -558,6 +537,10 @@ void PetWindow::hideEvent(QHideEvent *event) { m_chatInputDialog->hide(); } + if (m_chatHistoryPanel) + { + m_chatHistoryPanel->hide(); + } QWidget::hideEvent(event); } diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 81aed50..6803a25 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -18,6 +18,7 @@ class QHideEvent; class QMoveEvent; class QPixmap; class ChatBubble; +class ChatHistoryPanel; class ChatInputDialog; class ConversationManager; class LLMProvider; @@ -54,6 +55,7 @@ private: void clearConversation(); void cancelActiveAIRequest(); void showConversationHistory(); + void refreshChatHistoryPanel(); bool hasActiveAIRequest() const; void resetBubbleAutoHideTimer(); QPoint chatInputAnchorPosition() const; @@ -70,6 +72,7 @@ private: void setAlwaysOnTop(bool enabled); std::unique_ptr m_chatBubble; + std::unique_ptr m_chatHistoryPanel; std::unique_ptr m_chatInputDialog; std::unique_ptr m_conversationManager; std::unique_ptr m_aiTestProvider;