1429 lines
40 KiB
C++
1429 lines
40 KiB
C++
#include "PetWindow.h"
|
||
|
||
#include "../ai/AIProviderFactory.h"
|
||
#include "../ai/ConversationManager.h"
|
||
#include "../ai/ConversationStore.h"
|
||
#include "../character/CharacterPackageLoader.h"
|
||
#include "../character/CharacterPackageRepository.h"
|
||
#include "../config/ConfigManager.h"
|
||
#include "../util/Logger.h"
|
||
#include "ChatBubble.h"
|
||
#include "ChatHistoryPanel.h"
|
||
#include "ChatInputDialog.h"
|
||
#include "PetView.h"
|
||
#include "SettingsDialog.h"
|
||
|
||
#include <QAction>
|
||
#include <QContextMenuEvent>
|
||
#include <QCursor>
|
||
#include <QDialog>
|
||
#include <QGuiApplication>
|
||
#include <QHideEvent>
|
||
#include <QList>
|
||
#include <QMenu>
|
||
#include <QMouseEvent>
|
||
#include <QPixmap>
|
||
#include <QPointF>
|
||
#include <QPointer>
|
||
#include <QRandomGenerator>
|
||
#include <QScreen>
|
||
#include <QSet>
|
||
#include <QShowEvent>
|
||
#include <QStringList>
|
||
#include <QVBoxLayout>
|
||
#include <QtGlobal>
|
||
|
||
#include <algorithm>
|
||
#include <memory>
|
||
|
||
namespace
|
||
{
|
||
constexpr int MaxUserMessageLength = 4000;
|
||
constexpr int ChatInputLowerOffsetY = 48;
|
||
constexpr int StreamBubbleUpdateIntervalMs = 80;
|
||
constexpr int MinAnimationTargetSide = 32;
|
||
constexpr int MaxAnimationTargetSide = 2048;
|
||
constexpr int LowPowerFpsCap = 6;
|
||
constexpr int ChatFinishedReturnDelayMs = 1500;
|
||
constexpr int StandardPrewarmIntervalMs = 800;
|
||
constexpr int LowPowerPrewarmIntervalMs = 1500;
|
||
constexpr qint64 BytesPerMegabyte = 1024 * 1024;
|
||
|
||
int boundedAnimationTargetSide(double sideLength)
|
||
{
|
||
const double boundedSideLength = qBound(
|
||
static_cast<double>(MinAnimationTargetSide),
|
||
sideLength,
|
||
static_cast<double>(MaxAnimationTargetSide));
|
||
return qRound(boundedSideLength);
|
||
}
|
||
|
||
int evenBoundedHistoryLimit(int value, int minimum, int maximum)
|
||
{
|
||
const int boundedValue = qBound(minimum, value, maximum);
|
||
return boundedValue - (boundedValue % 2);
|
||
}
|
||
|
||
QString megabytesText(qint64 bytes)
|
||
{
|
||
return QString::number(static_cast<double>(bytes) / static_cast<double>(BytesPerMegabyte), 'f', 1);
|
||
}
|
||
|
||
AppConfig normalizedAppConfig(AppConfig config)
|
||
{
|
||
config.scale = qBound(0.5, config.scale, 2.0);
|
||
if (config.performanceMode != QStringLiteral("standard")
|
||
&& config.performanceMode != QStringLiteral("low-power"))
|
||
{
|
||
config.performanceMode = QStringLiteral("standard");
|
||
}
|
||
config.animationCacheLimitMb = qBound(64, config.animationCacheLimitMb, 1024);
|
||
config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200);
|
||
config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000);
|
||
config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000);
|
||
return config;
|
||
}
|
||
|
||
QString userVisibleErrorMessage(const ChatResponse &response)
|
||
{
|
||
QString message = response.errorMessage.trimmed();
|
||
if (message.isEmpty())
|
||
{
|
||
message = QStringLiteral("未知错误。");
|
||
}
|
||
|
||
if (response.httpStatus > 0)
|
||
{
|
||
message = QStringLiteral("HTTP ") + QString::number(response.httpStatus) + QStringLiteral(":") + message;
|
||
}
|
||
|
||
return message;
|
||
}
|
||
|
||
}
|
||
|
||
PetWindow::PetWindow(QWidget *parent)
|
||
: QWidget(parent)
|
||
, m_chatBubble(std::make_unique<ChatBubble>())
|
||
, 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)
|
||
, m_centerNextFrame(false)
|
||
{
|
||
setAttribute(Qt::WA_TranslucentBackground);
|
||
setWindowFlags(Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint);
|
||
setMouseTracking(true);
|
||
|
||
auto *layout = new QVBoxLayout(this);
|
||
layout->setContentsMargins(0, 0, 0, 0);
|
||
layout->addWidget(m_petView);
|
||
|
||
m_frameAnimator.setFrameChangedCallback([this](const QPixmap &pixmap) {
|
||
setDisplayPixmap(pixmap, m_centerNextFrame);
|
||
m_centerNextFrame = false;
|
||
});
|
||
|
||
m_frameAnimator.setClipFinishedCallback([this](const QString &nextState) {
|
||
playResolvedState(m_stateMachine.finishState(nextState), false);
|
||
});
|
||
|
||
m_idleBehaviorTimer.setSingleShot(true);
|
||
connect(&m_idleBehaviorTimer, &QTimer::timeout, this, [this]() {
|
||
playIdleBehavior();
|
||
});
|
||
|
||
m_behaviorReturnTimer.setSingleShot(true);
|
||
connect(&m_behaviorReturnTimer, &QTimer::timeout, this, [this]() {
|
||
returnToIdleFromBehavior();
|
||
});
|
||
|
||
m_streamBubbleUpdateTimer.setSingleShot(true);
|
||
connect(&m_streamBubbleUpdateTimer, &QTimer::timeout, this, [this]() {
|
||
flushStreamingBubble(false);
|
||
});
|
||
|
||
m_animationPrewarmTimer.setSingleShot(true);
|
||
connect(&m_animationPrewarmTimer, &QTimer::timeout, this, [this]() {
|
||
processAnimationPrewarm();
|
||
});
|
||
|
||
QPointer<PetWindow> window(this);
|
||
m_chatInputDialog->setSubmitCallback([window](const QString &message) {
|
||
return !window.isNull() && window->submitChatMessage(message);
|
||
});
|
||
|
||
loadInitialImage();
|
||
}
|
||
|
||
PetWindow::~PetWindow()
|
||
{
|
||
saveConversationHistoryIfNeeded();
|
||
}
|
||
|
||
void PetWindow::applyAppConfig(const AppConfig &config)
|
||
{
|
||
const AppConfig normalizedConfig = normalizedAppConfig(config);
|
||
const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|
||
|| m_appConfig.performanceMode != normalizedConfig.performanceMode
|
||
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad;
|
||
const bool animationCachePolicyChanged =
|
||
m_appConfig.enableAnimationPrewarm != normalizedConfig.enableAnimationPrewarm
|
||
|| m_appConfig.animationCacheLimitMb != normalizedConfig.animationCacheLimitMb
|
||
|| m_appConfig.unloadAnimationsWhenHidden != normalizedConfig.unloadAnimationsWhenHidden;
|
||
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))
|
||
{
|
||
move(m_appConfig.windowPosition);
|
||
}
|
||
|
||
if (rebuildClips && !m_characterPackage.states.isEmpty())
|
||
{
|
||
const QString previousState = m_stateMachine.currentState().isEmpty()
|
||
? QStringLiteral("idle")
|
||
: m_stateMachine.currentState();
|
||
|
||
m_frameAnimator.stop();
|
||
buildAnimationClips();
|
||
|
||
const QString nextState = m_clips.contains(previousState)
|
||
? previousState
|
||
: QStringLiteral("idle");
|
||
if (m_clips.contains(nextState))
|
||
{
|
||
playResolvedState(m_stateMachine.requestState(nextState), false);
|
||
}
|
||
}
|
||
|
||
if (isAnimationCacheManagementEnabled())
|
||
{
|
||
if (animationCachePolicyChanged && !rebuildClips)
|
||
{
|
||
m_animationPrewarmAttemptedStates.clear();
|
||
m_animationPrewarmQueue.clear();
|
||
trimAnimationCache(QStringLiteral("config updated"));
|
||
}
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
else
|
||
{
|
||
stopAnimationPrewarm();
|
||
}
|
||
}
|
||
|
||
AppConfig PetWindow::currentAppConfig() const
|
||
{
|
||
AppConfig config = m_appConfig;
|
||
config.windowPosition = pos();
|
||
config.hasWindowPosition = true;
|
||
config.alwaysOnTop = m_alwaysOnTop;
|
||
return config;
|
||
}
|
||
|
||
void PetWindow::pauseAnimation()
|
||
{
|
||
if (!m_appConfig.pauseWhenHidden)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_returnToIdleAfterResume = m_behaviorReturnTimer.isActive();
|
||
m_idleBehaviorTimer.stop();
|
||
m_behaviorReturnTimer.stop();
|
||
stopAnimationPrewarm();
|
||
m_frameAnimator.pause();
|
||
}
|
||
|
||
void PetWindow::resumeAnimation()
|
||
{
|
||
m_frameAnimator.resume();
|
||
|
||
if (m_stateMachine.currentState() == QStringLiteral("idle"))
|
||
{
|
||
scheduleIdleBehavior();
|
||
}
|
||
else if (m_returnToIdleAfterResume)
|
||
{
|
||
m_behaviorReturnTimer.start(4000);
|
||
}
|
||
|
||
m_returnToIdleAfterResume = false;
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
|
||
void PetWindow::showBubbleMessage(const QString &message)
|
||
{
|
||
m_chatBubble->showMessage(message, bubbleAnchorPosition());
|
||
}
|
||
|
||
void PetWindow::openSettingsDialog()
|
||
{
|
||
ConfigManager configManager;
|
||
SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() {
|
||
return isManualStateSwitchLocked();
|
||
}, [this]() {
|
||
clearConversation();
|
||
}, this);
|
||
if (dialog.exec() != QDialog::Accepted)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!configManager.saveAIConfigStore(dialog.aiConfigStore()))
|
||
{
|
||
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
|
||
}
|
||
|
||
applyAppConfig(dialog.appConfig());
|
||
if (!configManager.saveAppConfig(currentAppConfig()))
|
||
{
|
||
Logger::warning(QStringLiteral("Failed to save app config from settings dialog."));
|
||
}
|
||
}
|
||
|
||
void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled)
|
||
{
|
||
m_settingsFallbackInContextMenuEnabled = enabled;
|
||
}
|
||
|
||
void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||
{
|
||
resetBubbleAutoHideTimer();
|
||
|
||
QMenu menu(this);
|
||
|
||
QAction *topAction = menu.addAction(QStringLiteral("取消置顶"));
|
||
topAction->setCheckable(true);
|
||
topAction->setChecked(m_alwaysOnTop);
|
||
|
||
const bool aiRequestRunning = hasActiveAIRequest();
|
||
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
|
||
chatAction->setEnabled(!aiRequestRunning);
|
||
QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话"));
|
||
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消 AI 请求"));
|
||
cancelAIAction->setEnabled(aiRequestRunning);
|
||
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
|
||
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
|
||
QAction *settingsAction = nullptr;
|
||
if (m_settingsFallbackInContextMenuEnabled)
|
||
{
|
||
settingsAction = menu.addAction(QStringLiteral("设置"));
|
||
}
|
||
|
||
addStateTestActions(&menu);
|
||
|
||
menu.addSeparator();
|
||
QAction *exitAction = menu.addAction(QStringLiteral("退出"));
|
||
|
||
QAction *selectedAction = menu.exec(event->globalPos());
|
||
if (selectedAction == topAction)
|
||
{
|
||
setAlwaysOnTop(!m_alwaysOnTop);
|
||
}
|
||
else if (selectedAction == chatAction)
|
||
{
|
||
startChat();
|
||
}
|
||
else if (selectedAction == showConversationAction)
|
||
{
|
||
showConversationHistory();
|
||
}
|
||
else if (selectedAction == cancelAIAction)
|
||
{
|
||
cancelActiveAIRequest();
|
||
}
|
||
else if (selectedAction == clearConversationAction)
|
||
{
|
||
clearConversation();
|
||
}
|
||
else if (settingsAction != nullptr && selectedAction == settingsAction)
|
||
{
|
||
openSettingsDialog();
|
||
}
|
||
else if (selectedAction == exitAction)
|
||
{
|
||
close();
|
||
}
|
||
else if (selectedAction != nullptr && selectedAction->data().isValid())
|
||
{
|
||
if (isManualStateSwitchLocked())
|
||
{
|
||
return;
|
||
}
|
||
|
||
playState(selectedAction->data().toString(), false);
|
||
}
|
||
}
|
||
|
||
void PetWindow::startChat()
|
||
{
|
||
if (!m_chatInputDialog)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_chatInputDialog->showAt(chatInputAnchorPosition());
|
||
}
|
||
|
||
bool PetWindow::submitChatMessage(const QString &message)
|
||
{
|
||
if (!m_conversationManager || m_conversationManager->isBusy())
|
||
{
|
||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||
return false;
|
||
}
|
||
|
||
const QString trimmedMessage = message.trimmed();
|
||
if (trimmedMessage.isEmpty())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (trimmedMessage.size() > MaxUserMessageLength)
|
||
{
|
||
playState(QStringLiteral("error"), false);
|
||
showBubbleMessage(QStringLiteral("消息太长,请控制在 4000 字以内。"));
|
||
return false;
|
||
}
|
||
|
||
ConfigManager configManager;
|
||
AIConfig config = configManager.loadAIConfig();
|
||
QString errorMessage;
|
||
if (!AIProviderFactory::prepareRuntimeConfig(config, &errorMessage))
|
||
{
|
||
playState(QStringLiteral("error"), false);
|
||
showBubbleMessage(errorMessage);
|
||
return false;
|
||
}
|
||
|
||
std::unique_ptr<LLMProvider> provider = AIProviderFactory::createProvider(config);
|
||
if (!provider)
|
||
{
|
||
playState(QStringLiteral("error"), false);
|
||
showBubbleMessage(QStringLiteral("当前 Provider 协议暂未接入。"));
|
||
return false;
|
||
}
|
||
|
||
if (!m_conversationManager->setProvider(std::move(provider)))
|
||
{
|
||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||
return false;
|
||
}
|
||
|
||
stopAnimationPrewarm();
|
||
playState(QStringLiteral("think"), false);
|
||
m_streamingAssistantText.clear();
|
||
m_streamBubbleUpdateTimer.stop();
|
||
m_streamingChatActive = true;
|
||
m_streamingTalkStarted = false;
|
||
m_chatBubble->showMessage(QStringLiteral("正在思考..."), bubbleAnchorPosition(), 0);
|
||
|
||
QPointer<PetWindow> window(this);
|
||
m_conversationManager->sendUserMessageStreaming(
|
||
trimmedMessage,
|
||
[window](const QString &delta) {
|
||
if (!window.isNull())
|
||
{
|
||
window->handleChatStreamDelta(delta);
|
||
}
|
||
},
|
||
[window](const ChatResponse &response) {
|
||
if (window.isNull())
|
||
{
|
||
return;
|
||
}
|
||
|
||
window->m_streamBubbleUpdateTimer.stop();
|
||
if (response.success)
|
||
{
|
||
const bool shouldReturnToIdleAfterChat =
|
||
window->m_streamingTalkStarted
|
||
|| window->m_stateMachine.currentState() == QStringLiteral("think")
|
||
|| window->m_stateMachine.currentState() == QStringLiteral("talk")
|
||
|| window->m_frameAnimator.currentStateName() == QStringLiteral("think")
|
||
|| window->m_frameAnimator.currentStateName() == QStringLiteral("talk");
|
||
window->finishStreamingChat();
|
||
window->m_streamingAssistantText = response.content;
|
||
window->flushStreamingBubble(true);
|
||
window->saveConversationHistoryIfNeeded();
|
||
window->refreshChatHistoryPanel();
|
||
if (shouldReturnToIdleAfterChat)
|
||
{
|
||
window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs);
|
||
}
|
||
return;
|
||
}
|
||
|
||
window->cancelStreamingChat();
|
||
window->m_streamingAssistantText.clear();
|
||
window->playState(QStringLiteral("error"), false);
|
||
window->showBubbleMessage(QStringLiteral("AI 回复失败:") + userVisibleErrorMessage(response));
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
void PetWindow::clearConversation()
|
||
{
|
||
if (!m_conversationManager)
|
||
{
|
||
return;
|
||
}
|
||
|
||
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
|
||
? QStringLiteral("已取消 AI 请求,并清空对话。")
|
||
: QStringLiteral("对话已清空。"));
|
||
playState(QStringLiteral("idle"), false);
|
||
}
|
||
|
||
void PetWindow::cancelActiveAIRequest()
|
||
{
|
||
if (m_conversationManager && m_conversationManager->isBusy())
|
||
{
|
||
m_conversationManager->cancel();
|
||
cancelStreamingChat();
|
||
showBubbleMessage(QStringLiteral("AI 请求已取消。"));
|
||
playState(QStringLiteral("idle"), false);
|
||
return;
|
||
}
|
||
|
||
showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。"));
|
||
}
|
||
|
||
bool PetWindow::hasActiveAIRequest() const
|
||
{
|
||
return m_conversationManager && m_conversationManager->isBusy();
|
||
}
|
||
|
||
bool PetWindow::isManualStateSwitchLocked() const
|
||
{
|
||
if (m_streamingChatActive || hasActiveAIRequest())
|
||
{
|
||
return true;
|
||
}
|
||
|
||
const QString currentState = m_stateMachine.currentState();
|
||
return currentState == QStringLiteral("think") || currentState == QStringLiteral("talk");
|
||
}
|
||
|
||
void PetWindow::showConversationHistory()
|
||
{
|
||
const QVector<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>();
|
||
const int prunedCount = m_conversationManager ? m_conversationManager->prunedHistoryMessageCount() : 0;
|
||
m_chatHistoryPanel->setMessages(history, prunedCount);
|
||
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>();
|
||
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)
|
||
{
|
||
if (delta.isEmpty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_streamingAssistantText += delta;
|
||
if (m_streamingChatActive && !m_streamingTalkStarted)
|
||
{
|
||
m_streamingTalkStarted = true;
|
||
playState(QStringLiteral("talk"), false);
|
||
if (m_stateMachine.currentState() == QStringLiteral("talk"))
|
||
{
|
||
m_returnToIdleAfterResume = false;
|
||
}
|
||
}
|
||
|
||
if (!isVisible())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!m_streamBubbleUpdateTimer.isActive())
|
||
{
|
||
m_streamBubbleUpdateTimer.start(StreamBubbleUpdateIntervalMs);
|
||
}
|
||
}
|
||
|
||
void PetWindow::flushStreamingBubble(bool finalUpdate)
|
||
{
|
||
if (!isVisible() || m_streamingAssistantText.trimmed().isEmpty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_chatBubble->showMessage(
|
||
m_streamingAssistantText,
|
||
bubbleAnchorPosition(),
|
||
finalUpdate ? 10000 : 0,
|
||
true);
|
||
}
|
||
|
||
void PetWindow::finishStreamingChat()
|
||
{
|
||
m_streamingChatActive = false;
|
||
m_streamingTalkStarted = false;
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
|
||
void PetWindow::cancelStreamingChat()
|
||
{
|
||
m_streamBubbleUpdateTimer.stop();
|
||
m_streamingAssistantText.clear();
|
||
m_streamingChatActive = false;
|
||
m_streamingTalkStarted = false;
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
|
||
void PetWindow::resetBubbleAutoHideTimer()
|
||
{
|
||
if (m_chatBubble)
|
||
{
|
||
m_chatBubble->resetAutoHideTimer();
|
||
}
|
||
}
|
||
|
||
QPoint PetWindow::chatInputAnchorPosition() const
|
||
{
|
||
return frameGeometry().topLeft() + QPoint(width() / 2, height() / 2 + ChatInputLowerOffsetY);
|
||
}
|
||
|
||
void PetWindow::hideEvent(QHideEvent *event)
|
||
{
|
||
m_streamBubbleUpdateTimer.stop();
|
||
stopAnimationPrewarm();
|
||
if (m_chatBubble)
|
||
{
|
||
m_chatBubble->hideBubble();
|
||
}
|
||
if (m_chatInputDialog)
|
||
{
|
||
m_chatInputDialog->hide();
|
||
}
|
||
if (m_chatHistoryPanel)
|
||
{
|
||
m_chatHistoryPanel->hide();
|
||
}
|
||
|
||
if (isAnimationCacheManagementEnabled() && m_appConfig.unloadAnimationsWhenHidden)
|
||
{
|
||
unloadNonProtectedAnimationCache(QStringLiteral("window hidden"));
|
||
}
|
||
|
||
QWidget::hideEvent(event);
|
||
}
|
||
|
||
void PetWindow::showEvent(QShowEvent *event)
|
||
{
|
||
QWidget::showEvent(event);
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
|
||
void PetWindow::mouseDoubleClickEvent(QMouseEvent *event)
|
||
{
|
||
resetBubbleAutoHideTimer();
|
||
if (event->button() == Qt::LeftButton)
|
||
{
|
||
m_dragging = false;
|
||
playResolvedState(m_stateMachine.endDrag(), false);
|
||
startChat();
|
||
event->accept();
|
||
return;
|
||
}
|
||
|
||
QWidget::mouseDoubleClickEvent(event);
|
||
}
|
||
|
||
void PetWindow::mouseMoveEvent(QMouseEvent *event)
|
||
{
|
||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
||
{
|
||
resetBubbleAutoHideTimer();
|
||
move(event->globalPosition().toPoint() - m_dragOffset);
|
||
updateBubblePosition();
|
||
event->accept();
|
||
return;
|
||
}
|
||
|
||
QWidget::mouseMoveEvent(event);
|
||
}
|
||
|
||
void PetWindow::moveEvent(QMoveEvent *event)
|
||
{
|
||
QWidget::moveEvent(event);
|
||
updateBubblePosition();
|
||
}
|
||
|
||
void PetWindow::mousePressEvent(QMouseEvent *event)
|
||
{
|
||
resetBubbleAutoHideTimer();
|
||
|
||
if (event->button() == Qt::LeftButton)
|
||
{
|
||
m_dragging = true;
|
||
m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft();
|
||
stopAnimationPrewarm();
|
||
playResolvedState(m_stateMachine.beginDrag(), false);
|
||
event->accept();
|
||
return;
|
||
}
|
||
|
||
QWidget::mousePressEvent(event);
|
||
}
|
||
|
||
void PetWindow::mouseReleaseEvent(QMouseEvent *event)
|
||
{
|
||
resetBubbleAutoHideTimer();
|
||
|
||
if (event->button() == Qt::LeftButton)
|
||
{
|
||
m_dragging = false;
|
||
playResolvedState(m_stateMachine.endDrag(), false);
|
||
scheduleAnimationPrewarm();
|
||
event->accept();
|
||
return;
|
||
}
|
||
|
||
QWidget::mouseReleaseEvent(event);
|
||
}
|
||
|
||
void PetWindow::loadInitialImage()
|
||
{
|
||
QString loadError;
|
||
m_characterPackage = CharacterPackageLoader::load(CharacterPackageRepository::defaultPackagePath(), &loadError);
|
||
if (!loadError.isEmpty())
|
||
{
|
||
Logger::warning(QStringLiteral("Character package load failed: ") + loadError);
|
||
}
|
||
|
||
buildAnimationClips();
|
||
|
||
if (m_clips.contains(QStringLiteral("idle")))
|
||
{
|
||
playResolvedState(m_stateMachine.start(), true);
|
||
return;
|
||
}
|
||
|
||
setDisplayImage(CharacterPackageRepository::defaultPreviewPath(), true);
|
||
}
|
||
|
||
void PetWindow::buildAnimationClips()
|
||
{
|
||
stopAnimationPrewarm();
|
||
m_animationPrewarmQueue.clear();
|
||
m_animationPrewarmAttemptedStates.clear();
|
||
m_clipLastAccessSerial.clear();
|
||
m_clipAccessSerial = 0;
|
||
m_clips.clear();
|
||
|
||
QSet<QString> availableStates;
|
||
const QSize targetSize = animationTargetSize();
|
||
const bool loadFramesImmediately = !m_appConfig.enableLazyLoad;
|
||
for (auto iterator = m_characterPackage.states.constBegin(); iterator != m_characterPackage.states.constEnd(); ++iterator)
|
||
{
|
||
AnimationClip clip = AnimationClip::fromState(iterator.value(), targetSize, loadFramesImmediately);
|
||
clip.fps = effectiveAnimationFps(clip.fps);
|
||
if (!clip.isValid())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
availableStates.insert(iterator.key());
|
||
m_clips.insert(iterator.key(), clip);
|
||
}
|
||
|
||
m_stateMachine.setAvailableStates(availableStates);
|
||
}
|
||
|
||
void PetWindow::addStateTestActions(QMenu *menu)
|
||
{
|
||
QMenu *stateMenu = menu->addMenu(QStringLiteral("State Test"));
|
||
const bool stateSwitchLocked = isManualStateSwitchLocked();
|
||
const QStringList stateNames = {
|
||
QStringLiteral("idle"),
|
||
QStringLiteral("talk"),
|
||
QStringLiteral("think"),
|
||
QStringLiteral("sleep"),
|
||
QStringLiteral("happy"),
|
||
QStringLiteral("error"),
|
||
QStringLiteral("drag"),
|
||
};
|
||
|
||
for (const QString &stateName : stateNames)
|
||
{
|
||
if (!m_clips.contains(stateName))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
QAction *stateAction = stateMenu->addAction(stateName);
|
||
stateAction->setData(stateName);
|
||
}
|
||
|
||
stateMenu->setEnabled(!stateSwitchLocked && !stateMenu->actions().isEmpty());
|
||
}
|
||
|
||
void PetWindow::updateBubblePosition()
|
||
{
|
||
m_chatBubble->updateAnchorPosition(bubbleAnchorPosition());
|
||
}
|
||
|
||
QPoint PetWindow::bubbleAnchorPosition() const
|
||
{
|
||
const CharacterBase &base = m_characterPackage.base;
|
||
const CharacterBubble &bubble = m_characterPackage.bubble;
|
||
const double baseWidth = base.width > 0 ? static_cast<double>(base.width) : static_cast<double>(width());
|
||
const double baseHeight = base.height > 0 ? static_cast<double>(base.height) : static_cast<double>(height());
|
||
const double scaleX = baseWidth > 0.0 ? static_cast<double>(width()) / baseWidth : 1.0;
|
||
const double scaleY = baseHeight > 0.0 ? static_cast<double>(height()) / baseHeight : 1.0;
|
||
const QPointF localAnchor(
|
||
static_cast<double>(width()) * base.anchorX + bubble.offsetX * scaleX,
|
||
static_cast<double>(height()) * base.anchorY + bubble.offsetY * scaleY);
|
||
|
||
return frameGeometry().topLeft() + QPoint(qRound(localAnchor.x()), qRound(localAnchor.y()));
|
||
}
|
||
|
||
void PetWindow::playState(const QString &stateName, bool centerWindow)
|
||
{
|
||
playResolvedState(m_stateMachine.requestState(stateName), centerWindow);
|
||
}
|
||
|
||
void PetWindow::playResolvedState(const QString &stateName, bool centerWindow)
|
||
{
|
||
if (stateName.isEmpty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (m_streamingChatActive
|
||
&& stateName != QStringLiteral("error")
|
||
&& stateName != QStringLiteral("drag"))
|
||
{
|
||
const QString preferredHeldState = m_streamingTalkStarted
|
||
? QStringLiteral("talk")
|
||
: QStringLiteral("think");
|
||
const QString heldState = m_stateMachine.requestState(preferredHeldState, StateRequestSource::System);
|
||
|
||
if (stateName != heldState)
|
||
{
|
||
if (!heldState.isEmpty() && m_clips.contains(heldState))
|
||
{
|
||
playResolvedState(heldState, centerWindow);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (m_frameAnimator.currentStateName() == stateName && m_frameAnimator.isPlaying())
|
||
{
|
||
noteAnimationClipAccess(stateName);
|
||
return;
|
||
}
|
||
|
||
auto clipIterator = m_clips.find(stateName);
|
||
if (clipIterator == m_clips.end())
|
||
{
|
||
return;
|
||
}
|
||
|
||
AnimationClip *clip = &clipIterator.value();
|
||
const bool wasLoaded = clip->isLoaded();
|
||
if (!clip->ensureLoaded())
|
||
{
|
||
Logger::warning(QStringLiteral("Animation state failed to load: state=%1").arg(stateName));
|
||
return;
|
||
}
|
||
|
||
noteAnimationClipAccess(stateName);
|
||
if (!wasLoaded)
|
||
{
|
||
Logger::info(QStringLiteral("Animation state loaded: state=%1 frames=%2 cacheMb=%3")
|
||
.arg(stateName)
|
||
.arg(QString::number(clip->loadedFrameCount()))
|
||
.arg(megabytesText(clip->estimatedMemoryBytes())));
|
||
}
|
||
|
||
m_idleBehaviorTimer.stop();
|
||
m_behaviorReturnTimer.stop();
|
||
m_centerNextFrame = centerWindow;
|
||
m_frameAnimator.play(clip);
|
||
|
||
if (stateName == QStringLiteral("idle"))
|
||
{
|
||
scheduleIdleBehavior();
|
||
}
|
||
else if (clip->loop)
|
||
{
|
||
m_behaviorReturnTimer.start(4000);
|
||
}
|
||
|
||
trimAnimationCache(QStringLiteral("state played"));
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
|
||
QSize PetWindow::animationTargetSize() const
|
||
{
|
||
const CharacterBase &base = m_characterPackage.base;
|
||
const double totalScale = base.scale * m_appConfig.scale;
|
||
return QSize(
|
||
boundedAnimationTargetSide(static_cast<double>(base.width) * totalScale),
|
||
boundedAnimationTargetSide(static_cast<double>(base.height) * totalScale));
|
||
}
|
||
|
||
int PetWindow::effectiveAnimationFps(int fps) const
|
||
{
|
||
if (isLowPowerMode())
|
||
{
|
||
return qMax(1, qMin(fps, LowPowerFpsCap));
|
||
}
|
||
|
||
return qMax(1, fps);
|
||
}
|
||
|
||
bool PetWindow::isLowPowerMode() const
|
||
{
|
||
return m_appConfig.performanceMode == QStringLiteral("low-power");
|
||
}
|
||
|
||
bool PetWindow::isAnimationCacheManagementEnabled() const
|
||
{
|
||
return m_appConfig.enableLazyLoad;
|
||
}
|
||
|
||
void PetWindow::rebuildAnimationPrewarmQueue()
|
||
{
|
||
m_animationPrewarmQueue.clear();
|
||
if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const auto appendStateIfNeeded = [this](const QString &stateName) {
|
||
if (stateName == QStringLiteral("idle")
|
||
|| m_animationPrewarmQueue.contains(stateName)
|
||
|| m_animationPrewarmAttemptedStates.contains(stateName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const auto iterator = m_clips.constFind(stateName);
|
||
if (iterator != m_clips.constEnd() && !iterator.value().isLoaded())
|
||
{
|
||
m_animationPrewarmQueue.append(stateName);
|
||
}
|
||
};
|
||
|
||
appendStateIfNeeded(QStringLiteral("drag"));
|
||
appendStateIfNeeded(QStringLiteral("think"));
|
||
appendStateIfNeeded(QStringLiteral("talk"));
|
||
|
||
QStringList stateNames = m_clips.keys();
|
||
stateNames.sort();
|
||
for (const QString &stateName : stateNames)
|
||
{
|
||
appendStateIfNeeded(stateName);
|
||
}
|
||
}
|
||
|
||
void PetWindow::scheduleAnimationPrewarm()
|
||
{
|
||
if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm)
|
||
{
|
||
stopAnimationPrewarm();
|
||
return;
|
||
}
|
||
|
||
if (!isVisible() || m_dragging || m_streamingChatActive || hasActiveAIRequest())
|
||
{
|
||
stopAnimationPrewarm();
|
||
return;
|
||
}
|
||
|
||
if (m_animationPrewarmQueue.isEmpty())
|
||
{
|
||
rebuildAnimationPrewarmQueue();
|
||
}
|
||
|
||
if (m_animationPrewarmQueue.isEmpty() || m_animationPrewarmTimer.isActive())
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_animationPrewarmTimer.start(isLowPowerMode() ? LowPowerPrewarmIntervalMs : StandardPrewarmIntervalMs);
|
||
}
|
||
|
||
void PetWindow::stopAnimationPrewarm()
|
||
{
|
||
m_animationPrewarmTimer.stop();
|
||
}
|
||
|
||
void PetWindow::processAnimationPrewarm()
|
||
{
|
||
m_animationPrewarmTimer.stop();
|
||
if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!isVisible() || m_dragging || m_streamingChatActive || hasActiveAIRequest())
|
||
{
|
||
return;
|
||
}
|
||
|
||
while (!m_animationPrewarmQueue.isEmpty())
|
||
{
|
||
const QString stateName = m_animationPrewarmQueue.takeFirst();
|
||
m_animationPrewarmAttemptedStates.insert(stateName);
|
||
auto clipIterator = m_clips.find(stateName);
|
||
if (clipIterator == m_clips.end() || clipIterator.value().isLoaded())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
AnimationClip &clip = clipIterator.value();
|
||
if (!clip.ensureLoaded())
|
||
{
|
||
Logger::warning(QStringLiteral("Animation state prewarm failed: state=%1").arg(stateName));
|
||
continue;
|
||
}
|
||
|
||
noteAnimationClipAccess(stateName);
|
||
Logger::info(QStringLiteral("Animation state prewarmed: state=%1 frames=%2 cacheMb=%3")
|
||
.arg(stateName)
|
||
.arg(QString::number(clip.loadedFrameCount()))
|
||
.arg(megabytesText(clip.estimatedMemoryBytes())));
|
||
trimAnimationCache(QStringLiteral("prewarm"));
|
||
break;
|
||
}
|
||
|
||
scheduleAnimationPrewarm();
|
||
}
|
||
|
||
void PetWindow::noteAnimationClipAccess(const QString &stateName)
|
||
{
|
||
if (!m_clips.contains(stateName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
++m_clipAccessSerial;
|
||
m_clipLastAccessSerial.insert(stateName, m_clipAccessSerial);
|
||
}
|
||
|
||
qint64 PetWindow::animationCacheLimitBytes() const
|
||
{
|
||
return static_cast<qint64>(m_appConfig.animationCacheLimitMb) * BytesPerMegabyte;
|
||
}
|
||
|
||
qint64 PetWindow::loadedAnimationCacheBytes() const
|
||
{
|
||
qint64 totalBytes = 0;
|
||
for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator)
|
||
{
|
||
totalBytes += iterator.value().estimatedMemoryBytes();
|
||
}
|
||
|
||
return totalBytes;
|
||
}
|
||
|
||
int PetWindow::loadedAnimationClipCount() const
|
||
{
|
||
int count = 0;
|
||
for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator)
|
||
{
|
||
if (iterator.value().isLoaded())
|
||
{
|
||
++count;
|
||
}
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
QSet<QString> PetWindow::protectedAnimationStates() const
|
||
{
|
||
QSet<QString> states;
|
||
states.insert(QStringLiteral("idle"));
|
||
|
||
const QString animatorState = m_frameAnimator.currentStateName();
|
||
if (!animatorState.isEmpty())
|
||
{
|
||
states.insert(animatorState);
|
||
}
|
||
|
||
const QString stateMachineState = m_stateMachine.currentState();
|
||
if (!stateMachineState.isEmpty())
|
||
{
|
||
states.insert(stateMachineState);
|
||
}
|
||
|
||
if (m_streamingChatActive || hasActiveAIRequest())
|
||
{
|
||
states.insert(QStringLiteral("think"));
|
||
states.insert(QStringLiteral("talk"));
|
||
}
|
||
|
||
if (m_dragging)
|
||
{
|
||
states.insert(QStringLiteral("drag"));
|
||
}
|
||
|
||
return states;
|
||
}
|
||
|
||
void PetWindow::trimAnimationCache(const QString &reason)
|
||
{
|
||
if (!isAnimationCacheManagementEnabled())
|
||
{
|
||
return;
|
||
}
|
||
|
||
qint64 totalBytes = loadedAnimationCacheBytes();
|
||
const qint64 limitBytes = animationCacheLimitBytes();
|
||
if (totalBytes <= limitBytes)
|
||
{
|
||
return;
|
||
}
|
||
|
||
struct UnloadCandidate
|
||
{
|
||
QString stateName;
|
||
qint64 lastAccessSerial = 0;
|
||
};
|
||
|
||
const QSet<QString> protectedStates = protectedAnimationStates();
|
||
QList<UnloadCandidate> candidates;
|
||
QStringList protectedLoadedStates;
|
||
for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator)
|
||
{
|
||
if (!iterator.value().isLoaded())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (protectedStates.contains(iterator.key()))
|
||
{
|
||
protectedLoadedStates.append(iterator.key());
|
||
continue;
|
||
}
|
||
|
||
candidates.append({iterator.key(), m_clipLastAccessSerial.value(iterator.key(), 0)});
|
||
}
|
||
|
||
std::sort(candidates.begin(), candidates.end(), [](const UnloadCandidate &left, const UnloadCandidate &right) {
|
||
if (left.lastAccessSerial == right.lastAccessSerial)
|
||
{
|
||
return left.stateName < right.stateName;
|
||
}
|
||
|
||
return left.lastAccessSerial < right.lastAccessSerial;
|
||
});
|
||
|
||
for (const UnloadCandidate &candidate : candidates)
|
||
{
|
||
if (totalBytes <= limitBytes)
|
||
{
|
||
break;
|
||
}
|
||
|
||
auto clipIterator = m_clips.find(candidate.stateName);
|
||
if (clipIterator == m_clips.end() || !clipIterator.value().isLoaded())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const qint64 freedBytes = clipIterator.value().estimatedMemoryBytes();
|
||
clipIterator.value().unloadFrames();
|
||
m_clipLastAccessSerial.remove(candidate.stateName);
|
||
totalBytes -= freedBytes;
|
||
Logger::info(QStringLiteral("Animation state unloaded: state=%1 reason=%2 freedMb=%3 cacheMb=%4 loadedStates=%5")
|
||
.arg(candidate.stateName)
|
||
.arg(reason)
|
||
.arg(megabytesText(freedBytes))
|
||
.arg(megabytesText(totalBytes))
|
||
.arg(QString::number(loadedAnimationClipCount())));
|
||
}
|
||
|
||
if (totalBytes > limitBytes)
|
||
{
|
||
protectedLoadedStates.sort();
|
||
Logger::warning(QStringLiteral("Animation cache remains over limit: reason=%1 cacheMb=%2 limitMb=%3 protectedStates=%4")
|
||
.arg(reason)
|
||
.arg(megabytesText(totalBytes))
|
||
.arg(QString::number(m_appConfig.animationCacheLimitMb))
|
||
.arg(protectedLoadedStates.join(QStringLiteral(","))));
|
||
}
|
||
}
|
||
|
||
void PetWindow::unloadNonProtectedAnimationCache(const QString &reason)
|
||
{
|
||
if (!isAnimationCacheManagementEnabled())
|
||
{
|
||
return;
|
||
}
|
||
|
||
const QSet<QString> protectedStates = protectedAnimationStates();
|
||
for (auto iterator = m_clips.begin(); iterator != m_clips.end(); ++iterator)
|
||
{
|
||
if (!iterator.value().isLoaded())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (protectedStates.contains(iterator.key()))
|
||
{
|
||
Logger::info(QStringLiteral("Animation state kept loaded: state=%1 reason=%2")
|
||
.arg(iterator.key())
|
||
.arg(reason));
|
||
continue;
|
||
}
|
||
|
||
const qint64 freedBytes = iterator.value().estimatedMemoryBytes();
|
||
iterator.value().unloadFrames();
|
||
m_clipLastAccessSerial.remove(iterator.key());
|
||
Logger::info(QStringLiteral("Animation state unloaded: state=%1 reason=%2 freedMb=%3 cacheMb=%4 loadedStates=%5")
|
||
.arg(iterator.key())
|
||
.arg(reason)
|
||
.arg(megabytesText(freedBytes))
|
||
.arg(megabytesText(loadedAnimationCacheBytes()))
|
||
.arg(QString::number(loadedAnimationClipCount())));
|
||
}
|
||
}
|
||
|
||
void PetWindow::scheduleIdleBehavior()
|
||
{
|
||
if (!m_clips.contains(QStringLiteral("idle")))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const int idleDelayMs = isLowPowerMode()
|
||
? QRandomGenerator::global()->bounded(16000, 30001)
|
||
: QRandomGenerator::global()->bounded(8000, 16001);
|
||
m_idleBehaviorTimer.start(idleDelayMs);
|
||
}
|
||
|
||
void PetWindow::playIdleBehavior()
|
||
{
|
||
if (m_dragging || m_stateMachine.currentState() != QStringLiteral("idle"))
|
||
{
|
||
scheduleIdleBehavior();
|
||
return;
|
||
}
|
||
|
||
QStringList candidateStates;
|
||
const QStringList preferredStates = {
|
||
QStringLiteral("think"),
|
||
QStringLiteral("sleep"),
|
||
QStringLiteral("happy"),
|
||
};
|
||
|
||
for (const QString &stateName : preferredStates)
|
||
{
|
||
if (m_clips.contains(stateName))
|
||
{
|
||
candidateStates.append(stateName);
|
||
}
|
||
}
|
||
|
||
if (candidateStates.isEmpty())
|
||
{
|
||
scheduleIdleBehavior();
|
||
return;
|
||
}
|
||
|
||
const int stateIndex = QRandomGenerator::global()->bounded(candidateStates.size());
|
||
playResolvedState(m_stateMachine.requestState(candidateStates.at(stateIndex), StateRequestSource::Automatic), false);
|
||
}
|
||
|
||
void PetWindow::returnToIdleFromBehavior()
|
||
{
|
||
if (m_streamingChatActive)
|
||
{
|
||
const QString heldState = m_streamingTalkStarted
|
||
? QStringLiteral("talk")
|
||
: QStringLiteral("think");
|
||
|
||
if (m_clips.contains(heldState))
|
||
{
|
||
playResolvedState(heldState, false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!m_dragging)
|
||
{
|
||
playResolvedState(m_stateMachine.finishState(QStringLiteral("idle")), false);
|
||
}
|
||
}
|
||
|
||
void PetWindow::setDisplayImage(const QString &imagePath, bool centerWindow)
|
||
{
|
||
QPixmap pixmap(imagePath);
|
||
if (pixmap.isNull())
|
||
{
|
||
m_petView->showFallbackText(QStringLiteral("QtDesktopPet"));
|
||
resize(240, 160);
|
||
return;
|
||
}
|
||
|
||
const QSize targetSize = animationTargetSize();
|
||
const QPixmap scaled = pixmap.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||
setDisplayPixmap(scaled, centerWindow);
|
||
}
|
||
|
||
void PetWindow::setDisplayPixmap(const QPixmap &pixmap, bool centerWindow)
|
||
{
|
||
const QSize previousSize = size();
|
||
m_petView->setFrame(pixmap);
|
||
resize(pixmap.size());
|
||
if (size() != previousSize)
|
||
{
|
||
updateBubblePosition();
|
||
}
|
||
|
||
if (centerWindow)
|
||
{
|
||
if (const QScreen *screen = QGuiApplication::primaryScreen())
|
||
{
|
||
const QRect available = screen->availableGeometry();
|
||
move(available.center() - rect().center());
|
||
}
|
||
}
|
||
}
|
||
|
||
bool PetWindow::isPointVisibleOnScreen(const QPoint &point) const
|
||
{
|
||
const QList<QScreen *> screens = QGuiApplication::screens();
|
||
for (const QScreen *screen : screens)
|
||
{
|
||
if (screen != nullptr && screen->availableGeometry().contains(point))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
void PetWindow::setAlwaysOnTop(bool enabled)
|
||
{
|
||
const bool currentlyOnTop = windowFlags().testFlag(Qt::WindowStaysOnTopHint);
|
||
if (m_alwaysOnTop == enabled && currentlyOnTop == enabled)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_alwaysOnTop = enabled;
|
||
const bool wasVisible = isVisible();
|
||
|
||
Qt::WindowFlags flags = windowFlags();
|
||
if (enabled)
|
||
{
|
||
flags |= Qt::WindowStaysOnTopHint;
|
||
}
|
||
else
|
||
{
|
||
flags &= ~Qt::WindowStaysOnTopHint;
|
||
}
|
||
|
||
setWindowFlags(flags);
|
||
if (wasVisible)
|
||
{
|
||
show();
|
||
}
|
||
}
|