添加气泡组件和 AI 配置结构
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <QPoint>
|
||||
#include <QString>
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "AIConfig.h"
|
||||
#include "AppConfig.h"
|
||||
|
||||
#include <QString>
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "ChatBubble.h"
|
||||
|
||||
#include <QFontMetrics>
|
||||
#include <QFrame>
|
||||
#include <QRect>
|
||||
#include <QScrollBar>
|
||||
#include <QSize>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <QTextEdit>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "../character/CharacterPackageLoader.h"
|
||||
#include "../util/Logger.h"
|
||||
#include "ChatBubble.h"
|
||||
#include "PetView.h"
|
||||
|
||||
#include <QAction>
|
||||
@@ -17,6 +18,8 @@
|
||||
#include <QStringList>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString characterPackagePath()
|
||||
@@ -32,6 +35,7 @@ QString previewImagePath()
|
||||
|
||||
PetWindow::PetWindow(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_chatBubble(std::make_unique<ChatBubble>())
|
||||
, 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);
|
||||
|
||||
@@ -11,30 +11,39 @@
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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<ChatBubble> m_chatBubble;
|
||||
PetView *m_petView;
|
||||
QTimer m_idleBehaviorTimer;
|
||||
QTimer m_behaviorReturnTimer;
|
||||
|
||||
Reference in New Issue
Block a user