From 5ece0ca30dbf97aaea658e5e60c155a24d31c666 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Fri, 29 May 2026 08:09:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B0=94=E6=B3=A1=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=20AI=20=E9=85=8D=E7=BD=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 3 + src/config/AIConfig.h | 16 +++++ src/config/AppConfig.h | 5 ++ src/config/ConfigManager.cpp | 113 +++++++++++++++++++++++++++++++++++ src/config/ConfigManager.h | 4 ++ src/ui/ChatBubble.cpp | 112 ++++++++++++++++++++++++++++++++++ src/ui/ChatBubble.h | 22 +++++++ src/ui/PetWindow.cpp | 45 ++++++++++++++ src/ui/PetWindow.h | 10 ++++ 9 files changed, 330 insertions(+) create mode 100644 src/config/AIConfig.h create mode 100644 src/ui/ChatBubble.cpp create mode 100644 src/ui/ChatBubble.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 76e8950..ffc559c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ qt_add_executable(QtDesktopPet src/character/CharacterPackageLoader.cpp src/character/FrameAnimator.h src/character/FrameAnimator.cpp + src/config/AIConfig.h src/config/AppConfig.h src/config/ConfigManager.h src/config/ConfigManager.cpp @@ -29,6 +30,8 @@ qt_add_executable(QtDesktopPet src/state/PetStateMachine.cpp src/tray/TrayController.h src/tray/TrayController.cpp + src/ui/ChatBubble.h + src/ui/ChatBubble.cpp src/ui/PetView.h src/ui/PetView.cpp src/ui/PetWindow.h diff --git a/src/config/AIConfig.h b/src/config/AIConfig.h new file mode 100644 index 0000000..3e2e3fc --- /dev/null +++ b/src/config/AIConfig.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +struct AIConfig +{ + QString providerType = QStringLiteral("openai-compatible"); + QString baseUrl; + QString apiKey; + QString model; + QString path = QStringLiteral("/chat/completions"); + bool stream = false; + int timeoutMs = 60000; + double temperature = 0.7; + int maxTokens = 1024; +}; diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index 422104b..85702de 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -1,10 +1,15 @@ #pragma once #include +#include struct AppConfig { QPoint windowPosition = QPoint(100, 100); bool hasWindowPosition = false; bool alwaysOnTop = true; + double scale = 1.0; + QString performanceMode = QStringLiteral("standard"); + bool pauseWhenHidden = true; + bool enableLazyLoad = true; }; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 3e1de54..35b4e96 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -13,6 +13,7 @@ namespace { const QString AppConfigFileName = QStringLiteral("app_config.json"); +const QString AIConfigFileName = QStringLiteral("ai_config.json"); QJsonObject windowObjectFromConfig(const AppConfig &config) { @@ -20,8 +21,33 @@ QJsonObject windowObjectFromConfig(const AppConfig &config) window.insert(QStringLiteral("x"), config.windowPosition.x()); window.insert(QStringLiteral("y"), config.windowPosition.y()); window.insert(QStringLiteral("alwaysOnTop"), config.alwaysOnTop); + window.insert(QStringLiteral("scale"), config.scale); return window; } + +QJsonObject performanceObjectFromConfig(const AppConfig &config) +{ + QJsonObject performance; + performance.insert(QStringLiteral("mode"), config.performanceMode); + performance.insert(QStringLiteral("pauseWhenHidden"), config.pauseWhenHidden); + performance.insert(QStringLiteral("enableLazyLoad"), config.enableLazyLoad); + return performance; +} + +QJsonObject objectFromAIConfig(const AIConfig &config) +{ + QJsonObject root; + root.insert(QStringLiteral("providerType"), config.providerType); + root.insert(QStringLiteral("baseUrl"), config.baseUrl); + root.insert(QStringLiteral("apiKey"), config.apiKey); + root.insert(QStringLiteral("model"), config.model); + root.insert(QStringLiteral("path"), config.path); + root.insert(QStringLiteral("stream"), config.stream); + root.insert(QStringLiteral("timeoutMs"), config.timeoutMs); + root.insert(QStringLiteral("temperature"), config.temperature); + root.insert(QStringLiteral("maxTokens"), config.maxTokens); + return root; +} } ConfigManager::ConfigManager() = default; @@ -67,6 +93,66 @@ AppConfig ConfigManager::loadAppConfig() const config.alwaysOnTop = window.value(QStringLiteral("alwaysOnTop")).toBool(config.alwaysOnTop); } + if (window.contains(QStringLiteral("scale"))) + { + config.scale = window.value(QStringLiteral("scale")).toDouble(config.scale); + } + + const QJsonObject performance = root.value(QStringLiteral("performance")).toObject(); + if (performance.contains(QStringLiteral("mode"))) + { + config.performanceMode = performance.value(QStringLiteral("mode")).toString(config.performanceMode); + } + + if (performance.contains(QStringLiteral("pauseWhenHidden"))) + { + config.pauseWhenHidden = performance.value(QStringLiteral("pauseWhenHidden")).toBool(config.pauseWhenHidden); + } + + if (performance.contains(QStringLiteral("enableLazyLoad"))) + { + config.enableLazyLoad = performance.value(QStringLiteral("enableLazyLoad")).toBool(config.enableLazyLoad); + } + + return config; +} + +AIConfig ConfigManager::loadAIConfig() const +{ + AIConfig config; + + QFile file(aiConfigPath()); + if (!file.exists()) + { + return config; + } + + if (!file.open(QIODevice::ReadOnly)) + { + Logger::warning(QStringLiteral("Unable to read AI config.")); + return config; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + file.close(); + backupBrokenConfig(aiConfigPath()); + Logger::warning(QStringLiteral("AI config is broken; default config will be used.")); + return config; + } + + const QJsonObject root = document.object(); + config.providerType = root.value(QStringLiteral("providerType")).toString(config.providerType); + config.baseUrl = root.value(QStringLiteral("baseUrl")).toString(config.baseUrl); + config.apiKey = root.value(QStringLiteral("apiKey")).toString(config.apiKey); + config.model = root.value(QStringLiteral("model")).toString(config.model); + config.path = root.value(QStringLiteral("path")).toString(config.path); + config.stream = root.value(QStringLiteral("stream")).toBool(config.stream); + config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs); + config.temperature = root.value(QStringLiteral("temperature")).toDouble(config.temperature); + config.maxTokens = root.value(QStringLiteral("maxTokens")).toInt(config.maxTokens); return config; } @@ -82,6 +168,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const QJsonObject root; root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); + root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); QFile file(appConfigPath()); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) @@ -94,11 +181,37 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const return file.write(document.toJson(QJsonDocument::Indented)) >= 0; } +bool ConfigManager::saveAIConfig(const AIConfig &config) const +{ + const QString directoryPath = configDirectoryPath(); + QDir directory(directoryPath); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) + { + Logger::warning(QStringLiteral("Unable to create config directory.")); + return false; + } + + QFile file(aiConfigPath()); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + Logger::warning(QStringLiteral("Unable to open AI config for writing.")); + return false; + } + + const QJsonDocument document(objectFromAIConfig(config)); + return file.write(document.toJson(QJsonDocument::Indented)) >= 0; +} + QString ConfigManager::appConfigPath() const { return QDir(configDirectoryPath()).filePath(AppConfigFileName); } +QString ConfigManager::aiConfigPath() const +{ + return QDir(configDirectoryPath()).filePath(AIConfigFileName); +} + QString ConfigManager::configDirectoryPath() const { const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); diff --git a/src/config/ConfigManager.h b/src/config/ConfigManager.h index 1dea700..bcb6f94 100644 --- a/src/config/ConfigManager.h +++ b/src/config/ConfigManager.h @@ -1,5 +1,6 @@ #pragma once +#include "AIConfig.h" #include "AppConfig.h" #include @@ -10,8 +11,11 @@ public: ConfigManager(); AppConfig loadAppConfig() const; + AIConfig loadAIConfig() const; bool saveAppConfig(const AppConfig &config) const; + bool saveAIConfig(const AIConfig &config) const; QString appConfigPath() const; + QString aiConfigPath() const; private: QString configDirectoryPath() const; diff --git a/src/ui/ChatBubble.cpp b/src/ui/ChatBubble.cpp new file mode 100644 index 0000000..457cd48 --- /dev/null +++ b/src/ui/ChatBubble.cpp @@ -0,0 +1,112 @@ +#include "ChatBubble.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int MinBubbleWidth = 120; +constexpr int MaxBubbleWidth = 420; +constexpr int MaxBubbleHeight = 220; +constexpr int BubbleOffsetY = 8; +constexpr int BubblePaddingWidth = 28; +constexpr int BubblePaddingHeight = 24; +} + +ChatBubble::ChatBubble(QWidget *parent) + : QWidget(parent) + , m_textEdit(new QTextEdit(this)) +{ + setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_ShowWithoutActivating); + + 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->setStyleSheet(QStringLiteral( + "QTextEdit {" + "background: rgba(255, 255, 255, 232);" + "color: #202124;" + "border: 1px solid rgba(32, 33, 36, 48);" + "border-radius: 8px;" + "padding: 10px 12px;" + "}" + "QScrollBar:vertical {" + "background: transparent;" + "width: 8px;" + "margin: 8px 4px 8px 0;" + "}" + "QScrollBar::handle:vertical {" + "background: rgba(32, 33, 36, 80);" + "border-radius: 4px;" + "}" + "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); + + m_hideTimer.setSingleShot(true); + connect(&m_hideTimer, &QTimer::timeout, this, [this]() { + hide(); + }); +} + +void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs) +{ + m_anchorPosition = anchorPosition; + const QString trimmed = message.trimmed(); + m_textEdit->setPlainText(trimmed); + m_textEdit->verticalScrollBar()->setValue(0); + + const QSize bubbleSize = preferredBubbleSize(trimmed); + m_textEdit->setFixedSize(bubbleSize); + setFixedSize(bubbleSize); + updatePosition(); + show(); + + if (durationMs > 0) + { + m_hideTimer.start(durationMs); + } +} + +void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition) +{ + m_anchorPosition = anchorPosition; + if (isVisible()) + { + updatePosition(); + } +} + +QSize ChatBubble::preferredBubbleSize(const QString &message) const +{ + const QFontMetrics metrics(m_textEdit->font()); + const int textWidth = metrics.horizontalAdvance(message); + const int preferredWidth = qBound(MinBubbleWidth, textWidth + BubblePaddingWidth, MaxBubbleWidth); + + const QRect wrappedRect = metrics.boundingRect( + QRect(0, 0, preferredWidth - BubblePaddingWidth, 10000), + Qt::TextWordWrap, + message); + + const int preferredHeight = qMin(wrappedRect.height() + BubblePaddingHeight, MaxBubbleHeight); + return QSize(preferredWidth, preferredHeight); +} + +void ChatBubble::updatePosition() +{ + move(m_anchorPosition.x() - width() / 2, m_anchorPosition.y() - height() - BubbleOffsetY); +} diff --git a/src/ui/ChatBubble.h b/src/ui/ChatBubble.h new file mode 100644 index 0000000..4da8d9b --- /dev/null +++ b/src/ui/ChatBubble.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +class ChatBubble : public QWidget +{ +public: + explicit ChatBubble(QWidget *parent = nullptr); + + void showMessage(const QString &message, const QPoint &anchorPosition, int durationMs = 10000); + void updateAnchorPosition(const QPoint &anchorPosition); + +private: + QSize preferredBubbleSize(const QString &message) const; + void updatePosition(); + + QTextEdit *m_textEdit = nullptr; + QTimer m_hideTimer; + QPoint m_anchorPosition; +}; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index a7c671c..012f55d 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -2,6 +2,7 @@ #include "../character/CharacterPackageLoader.h" #include "../util/Logger.h" +#include "ChatBubble.h" #include "PetView.h" #include @@ -17,6 +18,8 @@ #include #include +#include + namespace { QString characterPackagePath() @@ -32,6 +35,7 @@ QString previewImagePath() PetWindow::PetWindow(QWidget *parent) : QWidget(parent) + , m_chatBubble(std::make_unique()) , m_petView(new PetView(this)) , m_dragging(false) , m_alwaysOnTop(true) @@ -67,6 +71,8 @@ PetWindow::PetWindow(QWidget *parent) loadInitialImage(); } +PetWindow::~PetWindow() = default; + void PetWindow::applyAppConfig(const AppConfig &config) { setAlwaysOnTop(config.alwaysOnTop); @@ -110,6 +116,11 @@ void PetWindow::resumeAnimation() m_returnToIdleAfterResume = false; } +void PetWindow::showBubbleMessage(const QString &message) +{ + m_chatBubble->showMessage(message, bubbleAnchorPosition()); +} + void PetWindow::contextMenuEvent(QContextMenuEvent *event) { QMenu menu(this); @@ -118,6 +129,11 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) topAction->setCheckable(true); topAction->setChecked(m_alwaysOnTop); + QMenu *bubbleTestMenu = menu.addMenu(QStringLiteral("气泡测试")); + QAction *shortBubbleAction = bubbleTestMenu->addAction(QStringLiteral("短文本")); + QAction *maxBubbleAction = bubbleTestMenu->addAction(QStringLiteral("接近最大尺寸")); + QAction *scrollBubbleAction = bubbleTestMenu->addAction(QStringLiteral("超长滚动文本")); + addStateTestActions(&menu); menu.addSeparator(); @@ -128,6 +144,18 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) { setAlwaysOnTop(!m_alwaysOnTop); } + else if (selectedAction == shortBubbleAction) + { + showBubbleMessage(QStringLiteral("收到,马上处理。")); + } + else if (selectedAction == maxBubbleAction) + { + showBubbleMessage(QStringLiteral("这是一段用于测试气泡最大显示区域附近表现的文本。它应该自动换行,并在不出现滚动条的情况下尽量接近最大宽度和高度,方便观察边距、圆角、阴影和整体位置是否自然。")); + } + else if (selectedAction == scrollBubbleAction) + { + showBubbleMessage(QStringLiteral("这是一段用于测试超长 AI 回复的文本。第一段内容会让气泡快速达到最大高度,并触发垂直滚动条。用户应该可以通过滚动条查看后续内容,同时气泡本身不能继续无限变高。第二段内容继续补充更多文字,用来确认滚动区域、文本换行、右侧滚动条样式和顶部位置是否正常。第三段内容模拟模型输出较长解释时的情况:文本仍然需要保持清晰可读,不能遮挡整个桌面,也不能因为内容太多导致窗口尺寸失控。第四段内容用于确认自动隐藏计时仍然生效,并且再次打开气泡时滚动条会回到顶部。")); + } else if (selectedAction == exitAction) { close(); @@ -143,6 +171,7 @@ void PetWindow::mouseMoveEvent(QMouseEvent *event) if (m_dragging && (event->buttons() & Qt::LeftButton)) { move(event->globalPosition().toPoint() - m_dragOffset); + updateBubblePosition(); event->accept(); return; } @@ -150,6 +179,12 @@ void PetWindow::mouseMoveEvent(QMouseEvent *event) QWidget::mouseMoveEvent(event); } +void PetWindow::moveEvent(QMoveEvent *event) +{ + QWidget::moveEvent(event); + updateBubblePosition(); +} + void PetWindow::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) @@ -245,6 +280,16 @@ void PetWindow::addStateTestActions(QMenu *menu) stateMenu->setEnabled(!stateMenu->actions().isEmpty()); } +void PetWindow::updateBubblePosition() +{ + m_chatBubble->updateAnchorPosition(bubbleAnchorPosition()); +} + +QPoint PetWindow::bubbleAnchorPosition() const +{ + return frameGeometry().topLeft() + QPoint(width() / 2, 0); +} + void PetWindow::playState(const QString &stateName, bool centerWindow) { playResolvedState(m_stateMachine.requestState(stateName), centerWindow); diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index e82a8c4..6e34278 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -11,30 +11,39 @@ #include #include +#include + class QMenu; +class QMoveEvent; class QPixmap; +class ChatBubble; class PetView; class PetWindow : public QWidget { public: explicit PetWindow(QWidget *parent = nullptr); + ~PetWindow(); void applyAppConfig(const AppConfig &config); AppConfig currentAppConfig() const; void pauseAnimation(); void resumeAnimation(); + void showBubbleMessage(const QString &message); protected: void contextMenuEvent(QContextMenuEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; + void moveEvent(QMoveEvent *event) override; private: void loadInitialImage(); void buildAnimationClips(); void addStateTestActions(QMenu *menu); + void updateBubblePosition(); + QPoint bubbleAnchorPosition() const; void playState(const QString &stateName, bool centerWindow); void playResolvedState(const QString &stateName, bool centerWindow); void scheduleIdleBehavior(); @@ -45,6 +54,7 @@ private: bool isPointVisibleOnScreen(const QPoint &point) const; void setAlwaysOnTop(bool enabled); + std::unique_ptr m_chatBubble; PetView *m_petView; QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer;