完善应用设置与聊天状态逻辑
This commit is contained in:
@@ -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 中缩放 / 性能字段的实际应用
|
||||
- 角色导入/切换界面
|
||||
- 对话历史持久化
|
||||
- 角色包懒加载
|
||||
- 打包发布脚本
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -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. 发布前素材授权确认与打包验证
|
||||
```
|
||||
|
||||
+17
-20
@@ -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. 是否需要把对话历史持久化保存,还是第一版只保留内存会话
|
||||
```
|
||||
|
||||
@@ -2,28 +2,19 @@
|
||||
|
||||
#include <QSize>
|
||||
|
||||
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
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
#include <QPixmap>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
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<QPixmap> m_frames;
|
||||
};
|
||||
|
||||
+38
-10
@@ -2,11 +2,11 @@
|
||||
|
||||
#include <QApplication>
|
||||
#include <QEvent>
|
||||
#include <QFontMetrics>
|
||||
#include <QFrame>
|
||||
#include <QRect>
|
||||
#include <QScrollBar>
|
||||
#include <QSize>
|
||||
#include <QTextDocument>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
|
||||
@@ -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()
|
||||
|
||||
+143
-21
@@ -28,6 +28,7 @@
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <memory>
|
||||
|
||||
@@ -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<ChatMessage> history = m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>();
|
||||
@@ -640,10 +719,12 @@ void PetWindow::buildAnimationClips()
|
||||
m_clips.clear();
|
||||
|
||||
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)
|
||||
{
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<QString, AnimationClip> m_clips;
|
||||
FrameAnimator m_frameAnimator;
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <QSpinBox>
|
||||
#include <QTabWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
|
||||
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<QPair<QString, QString>> 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())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../config/AIConfig.h"
|
||||
#include "../config/AppConfig.h"
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user