完善应用设置与聊天状态逻辑

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