diff --git a/README.md b/README.md index d7f4693..6f51e05 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型 - `idle` / `talk` / `think` / `sleep` / `happy` / `drag` / `error` 状态 - 托盘显示、隐藏、退出 - 隐藏时暂停动画,显示时恢复动画 -- 保存窗口位置和置顶状态 +- 保存窗口位置、置顶、缩放和性能设置 - 文件日志和基础轮转 - 设置窗口 +- 应用设置:缩放、性能模式、隐藏暂停、懒加载 - AI Provider 分组配置 - Windows DPAPI 加密保存 API Key - 非 Windows 环境经用户确认后明文保存 API Key @@ -32,11 +33,8 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型 尚未实现: - 设置页内 AI 连通性测试 -- 缩放和性能模式 UI -- AppConfig 中缩放 / 性能字段的实际应用 - 角色导入/切换界面 - 对话历史持久化 -- 角色包懒加载 - 打包发布脚本 ## 技术栈 diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/Qt_DesktopPet_开发文档.md index 5fce7dc..cf291a2 100644 --- a/docs/Qt_DesktopPet_开发文档.md +++ b/docs/Qt_DesktopPet_开发文档.md @@ -1684,8 +1684,7 @@ MIT License 开源 ```text 1. 设置页内 AI 连通性测试 2. 对话历史内存上限和可选持久化 -3. AppConfig 中缩放、性能模式等字段的实际应用 -4. character.json 中 base、anchor、bubble offset 的解析与应用 -5. 角色包位置整理、角色切换和懒加载策略 -6. 发布前素材授权确认与打包验证 +3. character.json 中 base、anchor、bubble offset 的解析与应用 +4. 角色包位置整理和角色切换 +5. 发布前素材授权确认与打包验证 ``` diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index 8750f78..753ad3d 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -566,6 +566,8 @@ error :20 帧 已删除临时 AI 测试入口和气泡测试入口 已支持 OpenAI / Google / DeepSeek / Custom 配置分 Provider 保存 已移除废弃 Provider 配置入口,并在读取旧配置时清理废弃 Provider 配置 + 已支持应用设置页:缩放、性能模式、隐藏暂停、懒加载 + 已将 AppConfig 的 scale / performanceMode / pauseWhenHidden / enableLazyLoad 接入运行时 Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存 ``` @@ -573,12 +575,10 @@ error :20 帧 ```text 1. shiroko 角色包仍位于项目根目录 shiroko/,尚未移动到 resources/characters/shiroko -2. SettingsDialog 仍是最小设置界面,尚未包含 AI 测试按钮、应用设置、角色选择、缩放和性能模式 UI -3. ConfigManager 已有缩放和性能字段,但 PetWindow 尚未真正应用缩放、性能模式和角色选择 -4. CharacterPackage 尚未解析并应用 character.json 中的 base、anchor、bubble offset -5. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪 -6. 当前 FrameAnimator 采用当前角色包全部状态帧预加载,尚未做懒加载 -7. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 +2. SettingsDialog 仍是最小设置界面,尚未包含 AI 测试按钮、角色选择和更完整的分区布局 +3. CharacterPackage 尚未解析并应用 character.json 中的 base、anchor、bubble offset +4. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪 +5. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 ``` --- @@ -600,7 +600,11 @@ error :20 帧 - 长文本流式输出期间应持续 talk 3. 给 ConversationManager 增加内存历史上限,避免长期对话无限增长 4. 把 AI 测试能力迁移到后续设置页,不再放在角色右键菜单 -5. 更新设置页结构,为 AI、应用、角色、性能分区预留位置 +5. 用户手测应用设置: + - 缩放比例 + - 标准 / 低功耗性能模式 + - 隐藏到托盘时暂停动画 + - 动画懒加载 ``` 中期建议: @@ -608,16 +612,10 @@ error :20 帧 ```text 1. 完善设置界面: - AI 配置和测试 - - 置顶、缩放、性能模式 - 角色包路径和角色切换 -2. 应用 AppConfig 中已有但尚未落地的字段: - - scale - - performanceMode - - pauseWhenHidden - - enableLazyLoad -3. 解析并应用角色包 base / anchor / bubble 配置 -4. 评估是否移动 shiroko 到 resources/characters/shiroko -5. 补一轮可重复的稳定性与性能测试记录 +2. 解析并应用角色包 base / anchor / bubble 配置 +3. 评估是否移动 shiroko 到 resources/characters/shiroko +4. 补一轮可重复的稳定性与性能测试记录 ``` --- @@ -628,8 +626,7 @@ error :20 帧 ```text 1. 是否把 shiroko 移动到 resources/characters/shiroko -2. 是否保持当前“预加载全部当前角色状态帧”的策略,还是改成按状态懒加载 -3. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 -4. 设置页下一步先做 AI 测试入口,还是先做应用缩放 / 性能设置 -5. 是否需要把对话历史持久化保存,还是第一版只保留内存会话 +2. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 +3. 设置页下一步先做 AI 测试入口,还是先做角色包配置 +4. 是否需要把对话历史持久化保存,还是第一版只保留内存会话 ``` diff --git a/src/character/AnimationClip.cpp b/src/character/AnimationClip.cpp index 2abb32b..90092b7 100644 --- a/src/character/AnimationClip.cpp +++ b/src/character/AnimationClip.cpp @@ -2,28 +2,19 @@ #include -AnimationClip AnimationClip::fromState(const CharacterState &state, const QSize &targetSize) +AnimationClip AnimationClip::fromState(const CharacterState &state, const QSize &targetSize, bool loadFrames) { AnimationClip clip; clip.stateName = state.name; clip.fps = state.fps; clip.loop = state.loop; clip.nextState = state.nextState; + clip.m_framePaths = state.framePaths; + clip.m_targetSize = targetSize; - for (const QString &framePath : state.framePaths) + if (loadFrames) { - QPixmap pixmap(framePath); - if (pixmap.isNull()) - { - continue; - } - - if (targetSize.isValid()) - { - pixmap = pixmap.scaled(targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); - } - - clip.m_frames.append(pixmap); + clip.loadFrames(); } return clip; @@ -31,7 +22,41 @@ AnimationClip AnimationClip::fromState(const CharacterState &state, const QSize bool AnimationClip::isValid() const { - return fps > 0 && !m_frames.isEmpty(); + return fps > 0 && (!m_frames.isEmpty() || !m_framePaths.isEmpty()); +} + +bool AnimationClip::ensureLoaded() +{ + if (m_frames.isEmpty()) + { + loadFrames(); + } + + return !m_frames.isEmpty(); +} + +void AnimationClip::loadFrames() +{ + if (!m_frames.isEmpty()) + { + return; + } + + for (const QString &framePath : m_framePaths) + { + QPixmap pixmap(framePath); + if (pixmap.isNull()) + { + continue; + } + + if (m_targetSize.isValid()) + { + pixmap = pixmap.scaled(m_targetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + m_frames.append(pixmap); + } } const QPixmap &AnimationClip::frameAt(int index) const diff --git a/src/character/AnimationClip.h b/src/character/AnimationClip.h index ac874b9..7946536 100644 --- a/src/character/AnimationClip.h +++ b/src/character/AnimationClip.h @@ -5,14 +5,16 @@ #include #include #include +#include #include class AnimationClip { public: - static AnimationClip fromState(const CharacterState &state, const QSize &targetSize); + static AnimationClip fromState(const CharacterState &state, const QSize &targetSize, bool loadFrames = true); bool isValid() const; + bool ensureLoaded(); const QPixmap &frameAt(int index) const; int frameCount() const; @@ -22,5 +24,9 @@ public: QString nextState; private: + void loadFrames(); + + QStringList m_framePaths; + QSize m_targetSize; QVector m_frames; }; diff --git a/src/ui/ChatBubble.cpp b/src/ui/ChatBubble.cpp index b69f4d8..500cb09 100644 --- a/src/ui/ChatBubble.cpp +++ b/src/ui/ChatBubble.cpp @@ -2,11 +2,11 @@ #include #include -#include #include #include #include #include +#include #include #include @@ -18,6 +18,18 @@ constexpr int MaxBubbleHeight = 220; constexpr int BubbleOffsetY = 8; constexpr int BubblePaddingWidth = 28; constexpr int BubblePaddingHeight = 24; +constexpr int BubbleWidthSafetyMargin = 12; + +QSizeF documentSizeForWidth(const QTextEdit *textEdit, const QString &message, int width) +{ + QTextDocument document; + document.setDefaultFont(textEdit->font()); + document.setDefaultTextOption(textEdit->document()->defaultTextOption()); + document.setPlainText(message); + document.setDocumentMargin(textEdit->document()->documentMargin()); + document.setTextWidth(width); + return document.size(); +} } ChatBubble::ChatBubble(QWidget *parent) @@ -78,6 +90,10 @@ void ChatBubble::showMessage(const QString &message, const QPoint &anchorPositio m_textEdit->setPlainText(trimmed); const QSize bubbleSize = preferredBubbleSize(trimmed); + const int documentWidth = bubbleSize.width() - BubblePaddingWidth; + const QSizeF documentSize = documentSizeForWidth(m_textEdit, trimmed, documentWidth); + const bool needsVerticalScroll = documentSize.height() + BubblePaddingHeight > MaxBubbleHeight; + m_textEdit->setVerticalScrollBarPolicy(needsVerticalScroll ? Qt::ScrollBarAsNeeded : Qt::ScrollBarAlwaysOff); m_textEdit->setFixedSize(bubbleSize); setFixedSize(bubbleSize); updatePosition(); @@ -180,17 +196,29 @@ bool ChatBubble::isUserInteractionEvent(QEvent *event) const 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); + QTextDocument singleLineDocument; + singleLineDocument.setDefaultFont(m_textEdit->font()); + singleLineDocument.setDefaultTextOption(m_textEdit->document()->defaultTextOption()); + singleLineDocument.setPlainText(message); + singleLineDocument.setDocumentMargin(m_textEdit->document()->documentMargin()); + singleLineDocument.setTextWidth(-1); - const QRect wrappedRect = metrics.boundingRect( - QRect(0, 0, preferredWidth - BubblePaddingWidth, 10000), - Qt::TextWordWrap, - message); + const int singleLineWidth = qCeil(singleLineDocument.idealWidth()) + + BubblePaddingWidth + + BubbleWidthSafetyMargin; + int preferredWidth = qBound(MinBubbleWidth, singleLineWidth, MaxBubbleWidth); - const int preferredHeight = qMin(wrappedRect.height() + BubblePaddingHeight, MaxBubbleHeight); - return QSize(preferredWidth, preferredHeight); + QSizeF documentSize = documentSizeForWidth(m_textEdit, message, preferredWidth - BubblePaddingWidth); + int preferredHeight = qCeil(documentSize.height()) + BubblePaddingHeight; + if (preferredHeight > MaxBubbleHeight) + { + const int scrollbarWidth = m_textEdit->verticalScrollBar()->sizeHint().width(); + preferredWidth = qMin(MaxBubbleWidth, preferredWidth + scrollbarWidth); + documentSize = documentSizeForWidth(m_textEdit, message, preferredWidth - BubblePaddingWidth - scrollbarWidth); + preferredHeight = qCeil(documentSize.height()) + BubblePaddingHeight; + } + + return QSize(preferredWidth, qMin(preferredHeight, MaxBubbleHeight)); } void ChatBubble::updatePosition() diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 0fb5d62..3db02d8 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include @@ -46,6 +47,20 @@ QString previewImagePath() constexpr int MaxUserMessageLength = 4000; constexpr int ChatInputLowerOffsetY = 48; constexpr int StreamBubbleUpdateIntervalMs = 80; +constexpr int BaseAnimationTargetSize = 320; +constexpr int LowPowerFpsCap = 6; +constexpr int ChatFinishedReturnDelayMs = 1500; + +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"); + } + return config; +} bool populateRuntimeApiKey(AIConfig &config, QString *errorMessage) { @@ -199,17 +214,41 @@ PetWindow::~PetWindow() = default; void PetWindow::applyAppConfig(const AppConfig &config) { - setAlwaysOnTop(config.alwaysOnTop); + const AppConfig normalizedConfig = normalizedAppConfig(config); + const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale) + || m_appConfig.performanceMode != normalizedConfig.performanceMode + || m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; - if (config.hasWindowPosition && isPointVisibleOnScreen(config.windowPosition)) + m_appConfig = normalizedConfig; + setAlwaysOnTop(m_appConfig.alwaysOnTop); + + if (m_appConfig.hasWindowPosition && isPointVisibleOnScreen(m_appConfig.windowPosition)) { - move(config.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); + } } } AppConfig PetWindow::currentAppConfig() const { - AppConfig config; + AppConfig config = m_appConfig; config.windowPosition = pos(); config.hasWindowPosition = true; config.alwaysOnTop = m_alwaysOnTop; @@ -218,6 +257,11 @@ AppConfig PetWindow::currentAppConfig() const void PetWindow::pauseAnimation() { + if (!m_appConfig.pauseWhenHidden) + { + return; + } + m_returnToIdleAfterResume = m_behaviorReturnTimer.isActive(); m_idleBehaviorTimer.stop(); m_behaviorReturnTimer.stop(); @@ -294,10 +338,19 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) else if (selectedAction == settingsAction) { ConfigManager configManager; - SettingsDialog dialog(configManager.loadAIConfigStore(), this); - if (dialog.exec() == QDialog::Accepted && !configManager.saveAIConfigStore(dialog.aiConfigStore())) + SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), this); + if (dialog.exec() == QDialog::Accepted) { - Logger::warning(QStringLiteral("Failed to save AI config from settings dialog.")); + 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.")); + } } } else if (selectedAction == exitAction) @@ -306,6 +359,11 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) } else if (selectedAction != nullptr && selectedAction->data().isValid()) { + if (isManualStateSwitchLocked()) + { + return; + } + playState(selectedAction->data().toString(), false); } } @@ -390,10 +448,20 @@ bool PetWindow::submitChatMessage(const QString &message) 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->refreshChatHistoryPanel(); + if (shouldReturnToIdleAfterChat) + { + window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); + } return; } @@ -442,6 +510,17 @@ 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(); @@ -640,10 +719,12 @@ void PetWindow::buildAnimationClips() m_clips.clear(); QSet availableStates; - const QSize targetSize(320, 320); + 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); + AnimationClip clip = AnimationClip::fromState(iterator.value(), targetSize, loadFramesImmediately); + clip.fps = effectiveAnimationFps(clip.fps); if (!clip.isValid()) { continue; @@ -659,6 +740,7 @@ void PetWindow::buildAnimationClips() void PetWindow::addStateTestActions(QMenu *menu) { QMenu *stateMenu = menu->addMenu(QStringLiteral("State Test")); + const bool stateSwitchLocked = isManualStateSwitchLocked(); const QStringList stateNames = { QStringLiteral("idle"), QStringLiteral("talk"), @@ -680,7 +762,7 @@ void PetWindow::addStateTestActions(QMenu *menu) stateAction->setData(stateName); } - stateMenu->setEnabled(!stateMenu->actions().isEmpty()); + stateMenu->setEnabled(!stateSwitchLocked && !stateMenu->actions().isEmpty()); } void PetWindow::updateBubblePosition() @@ -709,17 +791,17 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) && stateName != QStringLiteral("error") && stateName != QStringLiteral("drag")) { - const QString heldState = m_streamingTalkStarted + const QString preferredHeldState = m_streamingTalkStarted ? QStringLiteral("talk") : QStringLiteral("think"); - - if (stateName != heldState && m_clips.contains(heldState)) - { - playResolvedState(heldState, centerWindow); - } + const QString heldState = m_stateMachine.requestState(preferredHeldState, StateRequestSource::System); if (stateName != heldState) { + if (!heldState.isEmpty() && m_clips.contains(heldState)) + { + playResolvedState(heldState, centerWindow); + } return; } } @@ -729,13 +811,18 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) return; } - auto clipIterator = m_clips.constFind(stateName); - if (clipIterator == m_clips.constEnd()) + auto clipIterator = m_clips.find(stateName); + if (clipIterator == m_clips.end()) + { + return; + } + + AnimationClip *clip = &clipIterator.value(); + if (!clip->ensureLoaded()) { return; } - const AnimationClip *clip = &clipIterator.value(); m_idleBehaviorTimer.stop(); m_behaviorReturnTimer.stop(); m_centerNextFrame = centerWindow; @@ -751,6 +838,28 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) } } +QSize PetWindow::animationTargetSize() const +{ + const int sideLength = qRound(BaseAnimationTargetSize * m_appConfig.scale); + const int boundedSideLength = qBound(BaseAnimationTargetSize / 2, sideLength, BaseAnimationTargetSize * 2); + return QSize(boundedSideLength, boundedSideLength); +} + +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"); +} + void PetWindow::scheduleIdleBehavior() { if (!m_clips.contains(QStringLiteral("idle"))) @@ -758,7 +867,9 @@ void PetWindow::scheduleIdleBehavior() return; } - const int idleDelayMs = QRandomGenerator::global()->bounded(8000, 16001); + const int idleDelayMs = isLowPowerMode() + ? QRandomGenerator::global()->bounded(16000, 30001) + : QRandomGenerator::global()->bounded(8000, 16001); m_idleBehaviorTimer.start(idleDelayMs); } @@ -826,15 +937,20 @@ void PetWindow::setDisplayImage(const QString &imagePath, bool centerWindow) return; } - const QSize targetSize(320, 320); + 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) { @@ -862,6 +978,12 @@ bool PetWindow::isPointVisibleOnScreen(const QPoint &point) const 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(); diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 797ac52..db47315 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -59,12 +59,16 @@ private: void finishStreamingChat(); void cancelStreamingChat(); bool hasActiveAIRequest() const; + bool isManualStateSwitchLocked() const; void resetBubbleAutoHideTimer(); QPoint chatInputAnchorPosition() const; void updateBubblePosition(); QPoint bubbleAnchorPosition() const; void playState(const QString &stateName, bool centerWindow); void playResolvedState(const QString &stateName, bool centerWindow); + QSize animationTargetSize() const; + int effectiveAnimationFps(int fps) const; + bool isLowPowerMode() const; void scheduleIdleBehavior(); void playIdleBehavior(); void returnToIdleFromBehavior(); @@ -81,6 +85,7 @@ private: QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; QTimer m_streamBubbleUpdateTimer; + AppConfig m_appConfig; CharacterPackage m_characterPackage; QMap m_clips; FrameAnimator m_frameAnimator; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index d8b2fe2..3b4e889 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -12,7 +12,10 @@ #include #include #include +#include #include +#include +#include namespace { @@ -28,7 +31,7 @@ QString normalizedProviderName(const QString &provider) } } -SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent) +SettingsDialog::SettingsDialog(const AIConfigStore &configStore, const AppConfig &appConfig, QWidget *parent) : QDialog(parent) , m_providerComboBox(new QComboBox(this)) , m_baseUrlEdit(new QLineEdit(this)) @@ -39,11 +42,16 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent , m_temperatureSpinBox(new QDoubleSpinBox(this)) , m_maxTokensSpinBox(new QSpinBox(this)) , m_allowPlainApiKeyCheckBox(new QCheckBox(QStringLiteral("允许在非 Windows 环境明文保存 API Key"), this)) + , m_scaleSpinBox(new QSpinBox(this)) + , m_performanceModeComboBox(new QComboBox(this)) + , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) + , m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this)) , m_configStore(configStore) + , m_appConfig(appConfig) { setWindowTitle(QStringLiteral("设置")); setModal(true); - resize(460, 360); + resize(500, 430); const QList> providers = { {QStringLiteral("openai"), QStringLiteral("OpenAI")}, @@ -72,6 +80,19 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent m_maxTokensSpinBox->setRange(1, 200000); + m_scaleSpinBox->setRange(50, 200); + m_scaleSpinBox->setSingleStep(10); + m_scaleSpinBox->setSuffix(QStringLiteral("%")); + m_scaleSpinBox->setValue(qRound(qBound(0.5, m_appConfig.scale, 2.0) * 100.0)); + + m_performanceModeComboBox->addItem(QStringLiteral("标准"), QStringLiteral("standard")); + m_performanceModeComboBox->addItem(QStringLiteral("低功耗"), QStringLiteral("low-power")); + const int performanceModeIndex = m_performanceModeComboBox->findData(m_appConfig.performanceMode); + m_performanceModeComboBox->setCurrentIndex(performanceModeIndex >= 0 ? performanceModeIndex : 0); + + m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden); + m_enableLazyLoadCheckBox->setChecked(m_appConfig.enableLazyLoad); + m_allowPlainApiKeyCheckBox->setVisible(!SecretStore::isEncryptionAvailable()); m_currentProvider = normalizedProviderName(m_providerComboBox->currentData().toString()); loadProviderConfig(m_currentProvider); @@ -87,6 +108,22 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent formLayout->addRow(QStringLiteral("Max Tokens"), m_maxTokensSpinBox); formLayout->addRow(QString(), m_allowPlainApiKeyCheckBox); + auto *aiPage = new QWidget(this); + aiPage->setLayout(formLayout); + + auto *appFormLayout = new QFormLayout(); + appFormLayout->addRow(QStringLiteral("缩放"), m_scaleSpinBox); + appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox); + appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox); + appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox); + + auto *appPage = new QWidget(this); + appPage->setLayout(appFormLayout); + + auto *tabWidget = new QTabWidget(this); + tabWidget->addTab(aiPage, QStringLiteral("AI")); + tabWidget->addTab(appPage, QStringLiteral("应用")); + auto *storageHintLabel = new QLabel(this); storageHintLabel->setWordWrap(true); storageHintLabel->setText(SecretStore::isEncryptionAvailable() @@ -98,7 +135,7 @@ SettingsDialog::SettingsDialog(const AIConfigStore &configStore, QWidget *parent connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); auto *layout = new QVBoxLayout(this); - layout->addLayout(formLayout); + layout->addWidget(tabWidget); layout->addWidget(storageHintLabel); layout->addWidget(buttonBox); @@ -118,6 +155,20 @@ AIConfigStore SettingsDialog::aiConfigStore() const return store; } +AppConfig SettingsDialog::appConfig() const +{ + AppConfig config = m_appConfig; + config.scale = m_scaleSpinBox->value() / 100.0; + config.performanceMode = m_performanceModeComboBox->currentData().toString(); + if (config.performanceMode.isEmpty()) + { + config.performanceMode = QStringLiteral("standard"); + } + config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->isChecked(); + config.enableLazyLoad = m_enableLazyLoadCheckBox->isChecked(); + return config; +} + void SettingsDialog::cacheCurrentProvider() { if (m_currentProvider.isEmpty()) diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 90150be..d36589f 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -1,6 +1,7 @@ #pragma once #include "../config/AIConfig.h" +#include "../config/AppConfig.h" #include @@ -13,9 +14,10 @@ class QSpinBox; class SettingsDialog : public QDialog { public: - explicit SettingsDialog(const AIConfigStore &configStore, QWidget *parent = nullptr); + explicit SettingsDialog(const AIConfigStore &configStore, const AppConfig &appConfig, QWidget *parent = nullptr); AIConfigStore aiConfigStore() const; + AppConfig appConfig() const; private: void cacheCurrentProvider(); @@ -33,6 +35,11 @@ private: QDoubleSpinBox *m_temperatureSpinBox = nullptr; QSpinBox *m_maxTokensSpinBox = nullptr; QCheckBox *m_allowPlainApiKeyCheckBox = nullptr; + QSpinBox *m_scaleSpinBox = nullptr; + QComboBox *m_performanceModeComboBox = nullptr; + QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; + QCheckBox *m_enableLazyLoadCheckBox = nullptr; AIConfigStore m_configStore; + AppConfig m_appConfig; QString m_currentProvider; };