#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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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(MinAnimationTargetSide), sideLength, static_cast(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(bytes) / static_cast(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()) , m_chatHistoryPanel(std::make_unique(this)) , m_chatInputDialog(std::make_unique(MaxUserMessageLength, this)) , m_conversationManager(std::make_unique()) , m_conversationStore(std::make_unique(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 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 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 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 history = m_conversationManager ? m_conversationManager->history() : QVector(); 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 history = m_conversationManager ? m_conversationManager->history() : QVector(); 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 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 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(base.width) : static_cast(width()); const double baseHeight = base.height > 0 ? static_cast(base.height) : static_cast(height()); const double scaleX = baseWidth > 0.0 ? static_cast(width()) / baseWidth : 1.0; const double scaleY = baseHeight > 0.0 ? static_cast(height()) / baseHeight : 1.0; const QPointF localAnchor( static_cast(width()) * base.anchorX + bubble.offsetX * scaleX, static_cast(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(base.width) * totalScale), boundedAnimationTargetSide(static_cast(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(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 PetWindow::protectedAnimationStates() const { QSet 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 protectedStates = protectedAnimationStates(); QList 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 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 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(); } }