收敛稳定性风险

This commit is contained in:
2026-05-31 16:27:49 +08:00
parent 49fd9b3130
commit 4388a168f1
31 changed files with 1445 additions and 384 deletions
+2
View File
@@ -7,6 +7,7 @@ cmake-build-*/
*.VC.db *.VC.db
*.VC.VC.opendb *.VC.VC.opendb
*.autosave *.autosave
*.broken.*.json
# Build outputs # Build outputs
*.exe *.exe
@@ -21,4 +22,5 @@ cmake-build-*/
# Runtime/config files generated during development # Runtime/config files generated during development
/config/ /config/
/logs/ /logs/
/reports/perf/
*.broken.json *.broken.json
+9
View File
@@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
qt_add_executable(QtDesktopPet qt_add_executable(QtDesktopPet
main.cpp main.cpp
src/ai/AIDiagnostics.h
src/ai/AIDiagnostics.cpp
src/ai/AIProviderFactory.h src/ai/AIProviderFactory.h
src/ai/AIProviderFactory.cpp src/ai/AIProviderFactory.cpp
src/ai/ConversationManager.h src/ai/ConversationManager.h
@@ -61,6 +63,8 @@ qt_add_executable(QtDesktopPet
src/ui/PetWindow.cpp src/ui/PetWindow.cpp
src/util/Logger.h src/util/Logger.h
src/util/Logger.cpp src/util/Logger.cpp
src/util/ResourcePaths.h
src/util/ResourcePaths.cpp
) )
target_compile_definitions(QtDesktopPet target_compile_definitions(QtDesktopPet
@@ -75,6 +79,11 @@ target_link_libraries(QtDesktopPet
) )
if (WIN32) if (WIN32)
set_source_files_properties(resources/icons/app_icon.rc
PROPERTIES
INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons"
)
target_sources(QtDesktopPet PRIVATE resources/icons/app_icon.rc)
target_compile_definitions(QtDesktopPet PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_compile_definitions(QtDesktopPet PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
target_link_libraries(QtDesktopPet PRIVATE Crypt32) target_link_libraries(QtDesktopPet PRIVATE Crypt32)
endif() endif()
+58 -8
View File
@@ -19,7 +19,9 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型
- 文件日志和基础轮转 - 文件日志和基础轮转
- 设置窗口 - 设置窗口
- 应用设置:缩放、性能模式、隐藏暂停、懒加载 - 应用设置:缩放、性能模式、隐藏暂停、懒加载
- 状态级动画预热和 LRU 缓存卸载
- AI Provider 分组配置 - AI Provider 分组配置
- 设置页内 AI 连通性测试
- Windows DPAPI 加密保存 API Key - Windows DPAPI 加密保存 API Key
- 非 Windows 环境经用户确认后明文保存 API Key - 非 Windows 环境经用户确认后明文保存 API Key
- OpenAI Compatible 聊天请求 - OpenAI Compatible 聊天请求
@@ -27,14 +29,15 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型
- 聊天输入框 - 聊天输入框
- AI 回复气泡 - AI 回复气泡
- 对话历史面板 - 对话历史面板
- 内存历史上限和可选本地历史保存
- AI 请求取消和对话清空 - AI 请求取消和对话清空
- Google Gemini 原生聊天请求 - Google Gemini 原生聊天请求
尚未实现: 尚未实现:
- 设置页内 AI 连通性测试
- 角色导入/切换界面 - 角色导入/切换界面
- 对话历史持久化 - 对话历史导出/管理界面
- 长期性能压测记录
- 打包发布脚本 - 打包发布脚本
## 技术栈 ## 技术栈
@@ -69,6 +72,18 @@ cmake --build build/mingw-debug
如果使用 Qt Creator,也可以直接打开根目录 `CMakeLists.txt`,选择合适的 Qt Kit 后构建。 如果使用 Qt Creator,也可以直接打开根目录 `CMakeLists.txt`,选择合适的 Qt Kit 后构建。
## 应用图标
当前应用图标位于:
```text
resources/icons/app_icon.ico
resources/icons/app_icon_1024.png
```
`app_icon.ico` 用于窗口图标、托盘图标和 Windows exe 资源图标;托盘图标加载失败时会回退到默认角色包的 `preview.png``app_icon_1024.png` 作为高分辨率源图保留。
运行时会优先读取可执行文件同级的 `resources/icons/`,找不到时回退到源码目录下的 `resources/icons/`。Windows exe 图标需要重新构建后生效。
## 角色包 ## 角色包
当前默认角色包位于: 当前默认角色包位于:
@@ -92,7 +107,18 @@ resources/characters/shiroko/
error/ error/
``` ```
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会加载当前角色包的各状态并按 `character.json` 中的 FPS 播放。 当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。
运行时会优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`
懒加载现状:
- `enableLazyLoad=true` 时,启动阶段只建立状态到帧路径的索引
- 某个状态首次播放时加载该状态的 PNG 帧
- 启动后会在主线程按批次预热常用状态,避免一次性加载全部帧
- 已加载状态按状态级 LRU 策略管理,超过动画缓存上限时卸载非保护状态
- 单轮预热不会反复重新加载刚被 LRU 卸载的状态,避免缓存上限较低时出现加载/卸载循环
- 隐藏到托盘时可释放非保护动画缓存
- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为
## 配置和日志 ## 配置和日志
@@ -102,10 +128,12 @@ resources/characters/shiroko/
QStandardPaths::AppConfigLocation/app_config.json QStandardPaths::AppConfigLocation/app_config.json
``` ```
配置损坏时会备份为: 配置损坏时会备份为带时间戳的文件
```text ```text
app_config.broken.json app_config.broken.yyyyMMdd-HHmmss.json
ai_config.broken.yyyyMMdd-HHmmss.json
conversation_history.broken.yyyyMMdd-HHmmss.json
``` ```
日志输出到文件,不输出到控制台: 日志输出到文件,不输出到控制台:
@@ -122,6 +150,28 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
- 最多保留 3 个旧日志文件 - 最多保留 3 个旧日志文件
- 文件名为 `QtDesktopPet.log.1``QtDesktopPet.log.2``QtDesktopPet.log.3` - 文件名为 `QtDesktopPet.log.1``QtDesktopPet.log.2``QtDesktopPet.log.3`
## 开发诊断
仓库提供开发用性能采样脚本,不进入普通用户发布包:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
```
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到:
```text
reports/perf/
```
`reports/perf/` 已加入 `.gitignore`。稳定性检查记录模板见:
```text
docs/performance_stability_check.md
```
发布包应排除 `tools/``docs/``reports/``build/``.git/`,只保留运行必需文件、`resources/characters/``resources/icons/``LICENSE` 和必要说明。
## AI 配置和聊天 ## AI 配置和聊天
当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口: 当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口:
@@ -148,15 +198,15 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
- API Key 不写入日志,不在错误提示中完整显示 - API Key 不写入日志,不在错误提示中完整显示
- 对话历史面板记录用户消息和 AI 最终回复 - 对话历史面板记录用户消息和 AI 最终回复
AI 测试入口已从角色右键菜单移除,后续会放入设置页。 AI 测试入口已从角色右键菜单移除,并迁移到设置页。
## 隐私说明 ## 隐私说明
程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。 程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。
日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header完整消息正文。 日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要
当前对话历史保存在内存中,请求上下文截取最近部分历史。后续仍需要补充内存历史上限和可选持久化策略 当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整
## 素材版权说明 ## 素材版权说明
+7 -6
View File
@@ -488,8 +488,9 @@ AI 回复完成 → idle
配置文件损坏时,不要直接覆盖,先备份为: 配置文件损坏时,不要直接覆盖,先备份为:
```text ```text
app_config.broken.json app_config.broken.yyyyMMdd-HHmmss.json
ai_config.broken.json ai_config.broken.yyyyMMdd-HHmmss.json
conversation_history.broken.yyyyMMdd-HHmmss.json
``` ```
然后再生成默认配置。 然后再生成默认配置。
@@ -1682,8 +1683,8 @@ MIT License 开源
当前仍需补齐: 当前仍需补齐:
```text ```text
1. 设置页内 AI 连通性测试 1. 角色包导入和角色切换
2. 对话历史内存上限和可选持久化 2. 对话历史导出、搜索或更完整管理界面
3. 角色包导入和角色切换 3. 发布前素材授权确认与打包验证
4. 发布前素材授权确认与打包验证 4. 长期性能压测记录
``` ```
+33 -16
View File
@@ -120,9 +120,10 @@ error 20 帧
```text ```text
1. character.json 中 base.width/base.height 当前为 512x512 1. character.json 中 base.width/base.height 当前为 512x512
2. 当前实现会预加载当前角色包的全部状态帧,后续需要观察内存和低配设备表现 2. 当前实现已接入状态级懒加载:enableLazyLoad=true 时先保存帧路径,状态首次播放时再加载对应帧
3. 如果项目公开发布或推送远程仓库,需要确认 shiroko 素材版权和再分发权限 3. 如果项目公开发布或推送远程仓库,需要确认 shiroko 素材版权和再分发权限
4. 版权不明确前,不应把它作为正式开源发布素材承诺 4. 版权不明确前,不应把它作为正式开源发布素材承诺
5. 当前已接入主线程分批预热和状态级 LRU 卸载,并避免低缓存上限下的重复预热/卸载循环;尚未做后台线程预热、单帧级缓存和长期压测记录
``` ```
--- ---
@@ -285,7 +286,7 @@ error 20 帧
1. AnimationClip 1. AnimationClip
2. FrameAnimator 2. FrameAnimator
3. 使用 QTimer 按 idle fps 播放帧 3. 使用 QTimer 按 idle fps 播放帧
4. 启动时加载 idle 帧到内存 4. 启动后播放 idle 时加载 idle 帧到内存
5. loop=true 时循环播放 5. loop=true 时循环播放
6. 仍然只播放 idle 状态 6. 仍然只播放 idle 状态
``` ```
@@ -371,7 +372,7 @@ error 20 帧
5. 保存窗口位置、置顶状态、缩放、性能模式 5. 保存窗口位置、置顶状态、缩放、性能模式
6. Logger 6. Logger
7. 基础日志轮转 7. 基础日志轮转
8. 配置损坏时备份为 .broken.json 8. app config、AI config 和 conversation history 损坏时备份为带时间戳的 .broken 文件
``` ```
暂不做: 暂不做:
@@ -470,7 +471,7 @@ error 20 帧
4. Model 为空时有提示 4. Model 为空时有提示
5. 错误 API Key 不崩溃 5. 错误 API Key 不崩溃
6. 错误 URL 或超时不崩溃 6. 错误 URL 或超时不崩溃
7. 日志不输出完整 API Key 7. 日志不输出完整 API Key、Authorization Header、完整消息正文和完整错误响应正文
8. 对话历史不会无限增长 8. 对话历史不会无限增长
``` ```
@@ -496,6 +497,16 @@ error 20 帧
7. 整理发布包 7. 整理发布包
``` ```
发布包排除:
```text
tools/
docs/
reports/
build/
.git/
```
暂不做: 暂不做:
```text ```text
@@ -550,7 +561,9 @@ error 20 帧
7. 阶段 5 稳定性与性能检查: 7. 阶段 5 稳定性与性能检查:
已做过一轮人工稳定性观察,包括静置、idle 动画、托盘隐藏/显示、重复状态切换和资源损坏兜底 已做过一轮人工稳定性观察,包括静置、idle 动画、托盘隐藏/显示、重复状态切换和资源损坏兜底
当前尚未形成自动化性能测试或长期压测记录 已新增开发用性能采样脚本 tools/perf_sample.ps1
已新增稳定性检查记录模板 docs/performance_stability_check.md
当前尚未形成长期压测记录
8. 阶段 6 AI 接入: 8. 阶段 6 AI 接入:
已新增 LLMProvider / OpenAICompatibleProvider / GoogleGeminiProvider / ConversationManager 已新增 LLMProvider / OpenAICompatibleProvider / GoogleGeminiProvider / ConversationManager
@@ -558,25 +571,30 @@ error 20 帧
已支持 Google Gemini generateContent / streamGenerateContent、x-goog-api-key、contents 多轮上下文和 systemInstruction 已支持 Google Gemini generateContent / streamGenerateContent、x-goog-api-key、contents 多轮上下文和 systemInstruction
已支持 SSE 流式输出,气泡中流式显示,历史面板只记录最终对话 已支持 SSE 流式输出,气泡中流式显示,历史面板只记录最终对话
已限制同一时间只允许一个 AI 请求 已限制同一时间只允许一个 AI 请求
已避免在日志中输出完整 API Key 和完整消息正文 已避免在日志中输出完整 API Key、Authorization Header、完整消息正文和完整错误响应正文
9. 阶段 7 UI 基础优化: 9. 阶段 7 UI 基础优化:
已新增 ChatBubble、ChatInputDialog、ChatHistoryPanel、SettingsDialog 已新增 ChatBubble、ChatInputDialog、ChatHistoryPanel、SettingsDialog
已支持右键聊天、显示对话、取消 AI 请求、清空对话、设置 已支持右键聊天、显示对话、取消 AI 请求、清空对话、设置
已删除临时 AI 测试入口和气泡测试入口 已删除临时 AI 测试入口和气泡测试入口
已将 AI 连通性测试迁移到设置页
已支持 OpenAI / Google / DeepSeek / Custom 配置分 Provider 保存 已支持 OpenAI / Google / DeepSeek / Custom 配置分 Provider 保存
已移除废弃 Provider 配置入口,并在读取旧配置时清理废弃 Provider 配置 已移除废弃 Provider 配置入口,并在读取旧配置时清理废弃 Provider 配置
已支持应用设置页:缩放、性能模式、隐藏暂停、懒加载 已支持内存历史上限和可选本地历史保存
将 AppConfig 的 scale / performanceMode / pauseWhenHidden / enableLazyLoad 接入运行时 支持应用设置页:缩放、性能模式、隐藏暂停、懒加载、动画预热、动画缓存上限、隐藏时释放动画缓存
已将 AppConfig 的 scale / performanceMode / pauseWhenHidden / enableLazyLoad / enableAnimationPrewarm / animationCacheLimitMb / unloadAnimationsWhenHidden 接入运行时
懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存 Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/
``` ```
当前实现与计划仍存在差异: 当前实现与计划仍存在差异:
```text ```text
1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局 1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局
2. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪 2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
3. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
``` ```
--- ---
@@ -596,9 +614,7 @@ error 20 帧
- 等待阶段拖动松开应回到 think - 等待阶段拖动松开应回到 think
- 收到首段回复后应进入 talk - 收到首段回复后应进入 talk
- 长文本流式输出期间应持续 talk - 长文本流式输出期间应持续 talk
3. 给 ConversationManager 增加内存历史上限,避免长期对话无限增长 3. 用户手测应用设置:
4. 把 AI 测试能力迁移到后续设置页,不再放在角色右键菜单
5. 用户手测应用设置:
- 缩放比例 - 缩放比例
- 标准 / 低功耗性能模式 - 标准 / 低功耗性能模式
- 隐藏到托盘时暂停动画 - 隐藏到托盘时暂停动画
@@ -611,7 +627,8 @@ error 20 帧
1. 完善设置界面: 1. 完善设置界面:
- AI 配置和测试 - AI 配置和测试
- 角色包导入和角色切换 - 角色包导入和角色切换
2. 补一轮可重复的稳定性与性能测试记录 2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录
3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略
``` ```
--- ---
@@ -622,6 +639,6 @@ error 20 帧
```text ```text
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
2. 设置页下一步先做 AI 测试入口,还是先做角色包配置 2. 设置页下一步先完善角色包配置,还是先补发布打包配置
3. 是否需要把对话历史持久化保存,还是第一版只保留内存会话 3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
``` ```
+107
View File
@@ -0,0 +1,107 @@
# 性能与稳定性检查记录
本文档用于记录可重复的阶段性性能与稳定性检查。采样脚本和本记录只面向开发与回归测试,不进入普通用户发布包。
## 测试环境
| 项目 | 内容 |
| --- | --- |
| 测试日期 | TODO |
| Git commit | TODO |
| 构建类型 | TODO: Debug / Release |
| 操作系统 | TODO |
| CPU | TODO |
| 内存 | TODO |
| 显示器 / DPI | TODO |
| Qt 版本 | TODO |
| 角色包 | `resources/characters/shiroko` |
| AppConfig 关键项 | scale=TODO, performanceMode=TODO, pauseWhenHidden=TODO, enableLazyLoad=TODO, enableAnimationPrewarm=TODO, animationCacheLimitMb=TODO, unloadAnimationsWhenHidden=TODO |
## 采样脚本
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
```
指定 PID 采样:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 12345 -DurationSeconds 600
```
输出目录默认是:
```text
reports/perf/
```
`reports/perf/` 已加入 `.gitignore`。需要沉淀结论时,只把摘要写入本文档,不提交原始采样 CSV。
## 检查项
| 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 |
| --- | --- | --- | --- | --- | --- | --- |
| 启动后静置 | 启动程序后不操作 5 分钟 | `tools/perf_sample.ps1` 默认参数 | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `tools/perf_sample.ps1 -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
| 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO |
| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `tools/perf_sample.ps1 -DurationSeconds 300` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO |
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `tools/perf_sample.ps1 -DurationSeconds 300` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
## 懒加载现状
当前实现是状态级懒加载、主线程分批预热和 LRU 卸载:
```text
1. enableLazyLoad=true 时,构建 AnimationClip 只保存帧路径
2. 某个状态首次播放时调用 ensureLoaded() 加载该状态帧
3. enableAnimationPrewarm=true 时,启动后在主线程按批次预热常用状态
4. 已加载状态按状态级 LRU 策略管理,超过 animationCacheLimitMb 时卸载非保护状态
5. 单轮预热记录会避免反复重新加载刚被 LRU 卸载的状态
6. unloadAnimationsWhenHidden=true 时,隐藏到托盘会释放非保护动画缓存
7. enableLazyLoad=false 时,仍保持启动阶段加载全部状态帧的兼容行为
```
尚未实现:
```text
1. 后台线程预热
2. 单帧级缓存
3. 自动化长期压测记录
```
## 发布包说明
普通用户发布包不包含:
```text
tools/
docs/
reports/
build/
.git/
```
普通用户发布包应包含运行必需内容:
```text
QtDesktopPet.exe
Qt 运行时依赖
resources/characters/
resources/icons/
LICENSE
README.md
```
运行时资源查找顺序:
```text
1. QtDesktopPet.exe 同级 resources/
2. 开发源码目录 resources/
```
+8
View File
@@ -1,11 +1,13 @@
#include <QApplication> #include <QApplication>
#include <QCoreApplication> #include <QCoreApplication>
#include <QIcon>
#include <QObject> #include <QObject>
#include "src/config/ConfigManager.h" #include "src/config/ConfigManager.h"
#include "src/tray/TrayController.h" #include "src/tray/TrayController.h"
#include "src/ui/PetWindow.h" #include "src/ui/PetWindow.h"
#include "src/util/Logger.h" #include "src/util/Logger.h"
#include "src/util/ResourcePaths.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
@@ -13,6 +15,12 @@ int main(int argc, char *argv[])
QApplication::setApplicationName("QtDesktopPet"); QApplication::setApplicationName("QtDesktopPet");
QApplication::setOrganizationName("QtDesktopPet"); QApplication::setOrganizationName("QtDesktopPet");
const QIcon appIcon(ResourcePaths::appIconPath());
if (!appIcon.isNull())
{
QApplication::setWindowIcon(appIcon);
}
Logger::info(QStringLiteral("Application started.")); Logger::info(QStringLiteral("Application started."));
ConfigManager configManager; ConfigManager configManager;
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+1
View File
@@ -0,0 +1 @@
IDI_ICON1 ICON "app_icon.ico"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+225
View File
@@ -0,0 +1,225 @@
#include "AIDiagnostics.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QStringList>
#include <QUrlQuery>
#include <QtGlobal>
namespace
{
constexpr int MaxBodySummaryLength = 360;
bool isSensitiveName(const QString &name)
{
const QString lowerName = name.toLower();
return lowerName.contains(QStringLiteral("key"))
|| lowerName.contains(QStringLiteral("token"))
|| lowerName.contains(QStringLiteral("secret"))
|| lowerName.contains(QStringLiteral("password"))
|| lowerName.contains(QStringLiteral("authorization"))
|| lowerName.contains(QStringLiteral("signature"))
|| lowerName.contains(QStringLiteral("apikey"))
|| lowerName == QStringLiteral("prompt")
|| lowerName == QStringLiteral("message")
|| lowerName == QStringLiteral("messages")
|| lowerName == QStringLiteral("content")
|| lowerName == QStringLiteral("contents");
}
QJsonValue sanitizedJsonValue(const QJsonValue &value, const QString &keyName = {})
{
if (!keyName.isEmpty() && isSensitiveName(keyName))
{
return QStringLiteral("<redacted>");
}
if (value.isObject())
{
QJsonObject sanitized;
const QJsonObject object = value.toObject();
for (auto iterator = object.constBegin(); iterator != object.constEnd(); ++iterator)
{
sanitized.insert(iterator.key(), sanitizedJsonValue(iterator.value(), iterator.key()));
}
return sanitized;
}
if (value.isArray())
{
QJsonArray sanitized;
const QJsonArray array = value.toArray();
for (const QJsonValue &item : array)
{
sanitized.append(sanitizedJsonValue(item));
}
return sanitized;
}
if (value.isString())
{
return AIDiagnostics::safeTextSummary(value.toString());
}
return value;
}
QString sanitizedUrlString(QUrl url)
{
url.setUserInfo(QString());
QUrlQuery query(url);
if (!query.isEmpty())
{
QUrlQuery sanitizedQuery;
const auto items = query.queryItems(QUrl::FullyDecoded);
for (const auto &item : items)
{
sanitizedQuery.addQueryItem(
item.first,
isSensitiveName(item.first) ? QStringLiteral("<redacted>") : item.second);
}
url.setQuery(sanitizedQuery);
}
return url.toString(QUrl::FullyEncoded);
}
}
namespace AIDiagnostics
{
QString oneLine(QString text)
{
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
text.replace(QLatin1Char('\t'), QLatin1Char(' '));
return text.simplified();
}
QString safeTextSummary(const QString &text, int maxLength)
{
QString summary = oneLine(text);
if (summary.size() <= maxLength)
{
return summary;
}
return summary.left(qMax(0, maxLength)) + QStringLiteral("...");
}
QString diagnosticContext(const AIConfig &config, const QUrl &url)
{
return QStringLiteral("provider=%1 protocol=%2 model=%3 url=%4 timeoutMs=%5 maxTokens=%6 temperature=%7")
.arg(config.provider.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.provider.trimmed())
.arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.protocol.trimmed())
.arg(config.model.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.model.trimmed())
.arg(sanitizedUrlString(url))
.arg(config.timeoutMs)
.arg(config.maxTokens)
.arg(QString::number(config.temperature, 'f', 2));
}
QString errorMessageFromBody(const QByteArray &body)
{
if (body.trimmed().isEmpty())
{
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return safeTextSummary(QString::fromUtf8(body), MaxBodySummaryLength);
}
const QJsonObject root = document.object();
QStringList details;
const QJsonValue errorValue = root.value(QStringLiteral("error"));
if (errorValue.isObject())
{
const QJsonObject error = errorValue.toObject();
const QString message = error.value(QStringLiteral("message")).toString().trimmed();
const QJsonValue codeValue = error.value(QStringLiteral("code"));
const QString code = codeValue.isString()
? codeValue.toString().trimmed()
: (codeValue.isDouble() ? QString::number(codeValue.toInt()) : QString());
const QString type = error.value(QStringLiteral("type")).toString().trimmed();
const QString status = error.value(QStringLiteral("status")).toString().trimmed();
if (!message.isEmpty())
{
details.append(safeTextSummary(message));
}
if (!code.isEmpty())
{
details.append(QStringLiteral("code=") + code);
}
if (!type.isEmpty() && type != code)
{
details.append(QStringLiteral("type=") + type);
}
if (!status.isEmpty() && status != code && status != type)
{
details.append(QStringLiteral("status=") + status);
}
}
else if (errorValue.isString())
{
const QString error = errorValue.toString().trimmed();
if (!error.isEmpty())
{
details.append(safeTextSummary(error));
}
}
const QString message = root.value(QStringLiteral("message")).toString().trimmed();
if (!message.isEmpty() && !details.contains(message))
{
details.append(safeTextSummary(message));
}
const QString requestId = root.value(QStringLiteral("request_id")).toString().trimmed();
if (!requestId.isEmpty())
{
details.append(QStringLiteral("request_id=") + requestId);
}
if (!details.isEmpty())
{
return details.join(QStringLiteral("; "));
}
return responseBodySummary(body);
}
QString responseBodySummary(const QByteArray &body)
{
if (body.trimmed().isEmpty())
{
return QStringLiteral("bytes=0");
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error == QJsonParseError::NoError)
{
const QJsonValue sanitized = document.isArray()
? sanitizedJsonValue(QJsonValue(document.array()))
: sanitizedJsonValue(QJsonValue(document.object()));
const QJsonDocument sanitizedDocument(
sanitized.isArray() ? QJsonDocument(sanitized.toArray()) : QJsonDocument(sanitized.toObject()));
return QStringLiteral("bytes=%1 json=%2")
.arg(QString::number(body.size()))
.arg(safeTextSummary(QString::fromUtf8(sanitizedDocument.toJson(QJsonDocument::Compact)), MaxBodySummaryLength));
}
return QStringLiteral("bytes=%1 preview=%2")
.arg(QString::number(body.size()))
.arg(safeTextSummary(QString::fromUtf8(body), MaxBodySummaryLength));
}
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "../config/AIConfig.h"
#include <QByteArray>
#include <QString>
#include <QUrl>
namespace AIDiagnostics
{
QString oneLine(QString text);
QString diagnosticContext(const AIConfig &config, const QUrl &url);
QString errorMessageFromBody(const QByteArray &body);
QString responseBodySummary(const QByteArray &body);
QString safeTextSummary(const QString &text, int maxLength = 240);
}
+37
View File
@@ -2,6 +2,7 @@
#include "../util/Logger.h" #include "../util/Logger.h"
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
@@ -63,6 +64,40 @@ int overlappingMessageCount(const QVector<ChatMessage> &existingMessages, const
return 0; return 0;
} }
void backupBrokenConversationHistory(const QString &filePath)
{
QFile file(filePath);
if (!file.exists())
{
return;
}
const QFileInfo fileInfo(filePath);
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss"));
QString backupPath = fileInfo.dir().filePath(
fileInfo.completeBaseName() + QStringLiteral(".broken.") + timestamp + QStringLiteral(".json"));
int suffix = 1;
while (QFile::exists(backupPath))
{
backupPath = fileInfo.dir().filePath(
fileInfo.completeBaseName()
+ QStringLiteral(".broken.")
+ timestamp
+ QStringLiteral("-")
+ QString::number(suffix)
+ QStringLiteral(".json"));
++suffix;
}
if (file.rename(backupPath))
{
Logger::warning(QStringLiteral("Broken conversation history was backed up: %1").arg(backupPath));
return;
}
Logger::warning(QStringLiteral("Failed to back up broken conversation history: %1").arg(filePath));
}
} }
ConversationStore::ConversationStore(QString filePath) ConversationStore::ConversationStore(QString filePath)
@@ -170,6 +205,8 @@ QVector<ChatMessage> ConversationStore::readMessages(QString *errorMessage) cons
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
file.close();
backupBrokenConversationHistory(m_filePath);
if (errorMessage != nullptr) if (errorMessage != nullptr)
{ {
*errorMessage = QStringLiteral("Conversation history is not valid JSON."); *errorMessage = QStringLiteral("Conversation history is not valid JSON.");
+35 -163
View File
@@ -1,5 +1,6 @@
#include "GoogleGeminiProvider.h" #include "GoogleGeminiProvider.h"
#include "AIDiagnostics.h"
#include "../util/Logger.h" #include "../util/Logger.h"
#include <QJsonArray> #include <QJsonArray>
@@ -15,135 +16,6 @@
namespace namespace
{ {
constexpr int MaxDiagnosticBodyLength = 1000;
QString trimmedResponseBody(const QByteArray &body)
{
const QString text = QString::fromUtf8(body).trimmed();
if (text.size() <= MaxDiagnosticBodyLength)
{
return text;
}
return text.left(MaxDiagnosticBodyLength) + QStringLiteral("...");
}
QString oneLine(QString text)
{
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
text.replace(QLatin1Char('\t'), QLatin1Char(' '));
return text.simplified();
}
bool isSensitiveQueryName(const QString &name)
{
const QString lowerName = name.toLower();
return lowerName.contains(QStringLiteral("key"))
|| lowerName.contains(QStringLiteral("token"))
|| lowerName.contains(QStringLiteral("secret"))
|| lowerName.contains(QStringLiteral("password"))
|| lowerName.contains(QStringLiteral("authorization"))
|| lowerName.contains(QStringLiteral("signature"));
}
QString sanitizedUrlString(QUrl url)
{
url.setUserInfo(QString());
QUrlQuery query(url);
if (!query.isEmpty())
{
QUrlQuery sanitizedQuery;
const auto items = query.queryItems(QUrl::FullyDecoded);
for (const auto &item : items)
{
sanitizedQuery.addQueryItem(
item.first,
isSensitiveQueryName(item.first) ? QStringLiteral("<redacted>") : item.second);
}
url.setQuery(sanitizedQuery);
}
return url.toString(QUrl::FullyEncoded);
}
QString diagnosticContext(const AIConfig &config, const QUrl &url)
{
return QStringLiteral("provider=%1 protocol=%2 model=%3 url=%4 timeoutMs=%5 maxTokens=%6 temperature=%7")
.arg(config.provider.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.provider.trimmed())
.arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.protocol.trimmed())
.arg(config.model.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.model.trimmed())
.arg(sanitizedUrlString(url))
.arg(config.timeoutMs)
.arg(config.maxTokens)
.arg(QString::number(config.temperature, 'f', 2));
}
QString errorMessageFromBody(const QByteArray &body)
{
if (body.trimmed().isEmpty())
{
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return trimmedResponseBody(body);
}
const QJsonObject root = document.object();
QStringList details;
const QJsonValue errorValue = root.value(QStringLiteral("error"));
if (errorValue.isObject())
{
const QJsonObject error = errorValue.toObject();
const QString message = error.value(QStringLiteral("message")).toString().trimmed();
const QJsonValue codeValue = error.value(QStringLiteral("code"));
const QString code = codeValue.isString()
? codeValue.toString().trimmed()
: (codeValue.isDouble() ? QString::number(codeValue.toInt()) : QString());
const QString status = error.value(QStringLiteral("status")).toString().trimmed();
if (!message.isEmpty())
{
details.append(message);
}
if (!code.isEmpty())
{
details.append(QStringLiteral("code=") + code);
}
if (!status.isEmpty() && status != code)
{
details.append(QStringLiteral("status=") + status);
}
}
else if (errorValue.isString())
{
const QString error = errorValue.toString().trimmed();
if (!error.isEmpty())
{
details.append(error);
}
}
const QString message = root.value(QStringLiteral("message")).toString().trimmed();
if (!message.isEmpty() && !details.contains(message))
{
details.append(message);
}
if (!details.isEmpty())
{
return details.join(QStringLiteral("; "));
}
return trimmedResponseBody(body);
}
QString normalizedGeminiModel(QString model) QString normalizedGeminiModel(QString model)
{ {
model = model.trimmed(); model = model.trimmed();
@@ -331,7 +203,7 @@ void GoogleGeminiProvider::sendChatRequestInternal(
const QJsonDocument document(buildPayload(request)); const QJsonDocument document(buildPayload(request));
const QByteArray payload = document.toJson(QJsonDocument::Compact); const QByteArray payload = document.toJson(QJsonDocument::Compact);
Logger::info(QStringLiteral("AI request started: %1 stream=%2 messageCount=%3 payloadBytes=%4") Logger::info(QStringLiteral("AI request started: %1 stream=%2 messageCount=%3 payloadBytes=%4")
.arg(diagnosticContext(m_config, url)) .arg(AIDiagnostics::diagnosticContext(m_config, url))
.arg(stream ? QStringLiteral("true") : QStringLiteral("false")) .arg(stream ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(request.messages.size())) .arg(QString::number(request.messages.size()))
.arg(QString::number(payload.size()))); .arg(QString::number(payload.size())));
@@ -403,7 +275,7 @@ void GoogleGeminiProvider::cancel()
if (!reply.isNull()) if (!reply.isNull())
{ {
Logger::info(QStringLiteral("AI request canceled: %1") Logger::info(QStringLiteral("AI request canceled: %1")
.arg(diagnosticContext(m_config, reply->request().url()))); .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())));
} }
clearReply(); clearReply();
@@ -512,13 +384,13 @@ ChatResponse GoogleGeminiProvider::parseResponse(QNetworkReply *reply, const QBy
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
const QString bodyError = errorMessageFromBody(body); const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\"") Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(static_cast<int>(reply->error())) .arg(static_cast<int>(reply->error()))
.arg(oneLine(reply->errorString())) .arg(AIDiagnostics::oneLine(reply->errorString()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
if (!bodyError.isEmpty()) if (!bodyError.isEmpty())
{ {
@@ -532,22 +404,22 @@ ChatResponse GoogleGeminiProvider::parseResponse(QNetworkReply *reply, const QBy
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" body=\"%4\"") Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" bodySummary=\"%4\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(parseError.errorString())) .arg(AIDiagnostics::oneLine(parseError.errorString()))
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus}; return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus};
} }
const QJsonObject root = document.object(); const QJsonObject root = document.object();
if (root.contains(QStringLiteral("error"))) if (root.contains(QStringLiteral("error")))
{ {
const QString bodyError = errorMessageFromBody(body); const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
Logger::warning(QStringLiteral("AI response returned error: %1 httpStatus=%2 body=\"%3\"") Logger::warning(QStringLiteral("AI response returned error: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, bodyError.isEmpty() ? QStringLiteral("AI response returned an error.") : bodyError, httpStatus}; return {false, {}, bodyError.isEmpty() ? QStringLiteral("AI response returned an error.") : bodyError, httpStatus};
} }
@@ -555,16 +427,16 @@ ChatResponse GoogleGeminiProvider::parseResponse(QNetworkReply *reply, const QBy
if (content.isEmpty()) if (content.isEmpty())
{ {
const QString reason = geminiEmptyResponseReason(root); const QString reason = geminiEmptyResponseReason(root);
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 reason=\"%3\" body=\"%4\"") Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 reason=\"%3\" bodySummary=\"%4\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(reason)) .arg(AIDiagnostics::safeTextSummary(reason))
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, reason, httpStatus}; return {false, {}, reason, httpStatus};
} }
Logger::info(QStringLiteral("AI request completed: %1 httpStatus=%2 responseChars=%3 responseBytes=%4") Logger::info(QStringLiteral("AI request completed: %1 httpStatus=%2 responseChars=%3 responseBytes=%4")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(QString::number(content.size())) .arg(QString::number(content.size()))
.arg(QString::number(body.size()))); .arg(QString::number(body.size())));
@@ -629,8 +501,8 @@ bool GoogleGeminiProvider::handleStreamPayload(const QByteArray &payload)
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3") Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3")
.arg(diagnosticContext(m_config, requestUrl(true))) .arg(AIDiagnostics::diagnosticContext(m_config, requestUrl(true)))
.arg(oneLine(parseError.errorString())) .arg(AIDiagnostics::oneLine(parseError.errorString()))
.arg(QString::number(payload.size()))); .arg(QString::number(payload.size())));
return true; return true;
} }
@@ -638,7 +510,7 @@ bool GoogleGeminiProvider::handleStreamPayload(const QByteArray &payload)
const QJsonObject root = document.object(); const QJsonObject root = document.object();
if (root.contains(QStringLiteral("error"))) if (root.contains(QStringLiteral("error")))
{ {
const QString bodyError = errorMessageFromBody(payload); const QString bodyError = AIDiagnostics::errorMessageFromBody(payload);
finishWithError(bodyError.isEmpty() ? QStringLiteral("AI stream returned an error.") : bodyError); finishWithError(bodyError.isEmpty() ? QStringLiteral("AI stream returned an error.") : bodyError);
return false; return false;
} }
@@ -666,13 +538,13 @@ ChatResponse GoogleGeminiProvider::finishStreamingResponse(QNetworkReply *reply,
: QByteArray(); : QByteArray();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
const QString bodyError = errorMessageFromBody(diagnosticBody); const QString bodyError = AIDiagnostics::errorMessageFromBody(diagnosticBody);
Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\" streamedChars=%6") Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\" streamedChars=%6")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(static_cast<int>(reply->error())) .arg(static_cast<int>(reply->error()))
.arg(oneLine(reply->errorString())) .arg(AIDiagnostics::oneLine(reply->errorString()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(diagnosticBody))) .arg(AIDiagnostics::responseBodySummary(diagnosticBody))
.arg(QString::number(m_streamedContent.size()))); .arg(QString::number(m_streamedContent.size())));
if (!bodyError.isEmpty()) if (!bodyError.isEmpty())
@@ -685,17 +557,17 @@ ChatResponse GoogleGeminiProvider::finishStreamingResponse(QNetworkReply *reply,
if (m_streamedContent.isEmpty()) if (m_streamedContent.isEmpty())
{ {
Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 body=\"%5\"") Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 bodySummary=\"%5\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(m_streamBuffer.size())) .arg(QString::number(m_streamBuffer.size()))
.arg(oneLine(trimmedResponseBody(diagnosticBody)))); .arg(AIDiagnostics::responseBodySummary(diagnosticBody)));
return {false, {}, QStringLiteral("Gemini streaming response content is empty."), httpStatus}; return {false, {}, QStringLiteral("Gemini streaming response content is empty."), httpStatus};
} }
Logger::info(QStringLiteral("AI streaming request completed: %1 httpStatus=%2 streamDone=%3 responseChars=%4") Logger::info(QStringLiteral("AI streaming request completed: %1 httpStatus=%2 streamDone=%3 responseChars=%4")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(m_streamedContent.size()))); .arg(QString::number(m_streamedContent.size())));
@@ -708,9 +580,9 @@ void GoogleGeminiProvider::finishWithError(const QString &message, int httpStatu
QPointer<QNetworkReply> reply = m_currentReply; QPointer<QNetworkReply> reply = m_currentReply;
const QUrl url = reply.isNull() ? requestUrl(m_streaming) : reply->request().url(); const QUrl url = reply.isNull() ? requestUrl(m_streaming) : reply->request().url();
Logger::warning(QStringLiteral("AI request finished with error: %1 httpStatus=%2 error=\"%3\"") Logger::warning(QStringLiteral("AI request finished with error: %1 httpStatus=%2 error=\"%3\"")
.arg(diagnosticContext(m_config, url)) .arg(AIDiagnostics::diagnosticContext(m_config, url))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(message))); .arg(AIDiagnostics::safeTextSummary(message)));
clearReply(); clearReply();
+33 -169
View File
@@ -1,5 +1,6 @@
#include "OpenAICompatibleProvider.h" #include "OpenAICompatibleProvider.h"
#include "AIDiagnostics.h"
#include "../util/Logger.h" #include "../util/Logger.h"
#include <QJsonArray> #include <QJsonArray>
@@ -8,147 +9,10 @@
#include <QJsonParseError> #include <QJsonParseError>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QStringList>
#include <QUrl> #include <QUrl>
#include <QUrlQuery>
#include <utility> #include <utility>
namespace
{
constexpr int MaxDiagnosticBodyLength = 1000;
QString trimmedResponseBody(const QByteArray &body)
{
const QString text = QString::fromUtf8(body).trimmed();
if (text.size() <= MaxDiagnosticBodyLength)
{
return text;
}
return text.left(MaxDiagnosticBodyLength) + QStringLiteral("...");
}
QString oneLine(QString text)
{
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
text.replace(QLatin1Char('\t'), QLatin1Char(' '));
return text.simplified();
}
bool isSensitiveQueryName(const QString &name)
{
const QString lowerName = name.toLower();
return lowerName.contains(QStringLiteral("key"))
|| lowerName.contains(QStringLiteral("token"))
|| lowerName.contains(QStringLiteral("secret"))
|| lowerName.contains(QStringLiteral("password"))
|| lowerName.contains(QStringLiteral("authorization"))
|| lowerName.contains(QStringLiteral("signature"));
}
QString sanitizedUrlString(QUrl url)
{
url.setUserInfo(QString());
QUrlQuery query(url);
if (!query.isEmpty())
{
QUrlQuery sanitizedQuery;
const auto items = query.queryItems(QUrl::FullyDecoded);
for (const auto &item : items)
{
sanitizedQuery.addQueryItem(
item.first,
isSensitiveQueryName(item.first) ? QStringLiteral("<redacted>") : item.second);
}
url.setQuery(sanitizedQuery);
}
return url.toString(QUrl::FullyEncoded);
}
QString diagnosticContext(const AIConfig &config, const QUrl &url)
{
return QStringLiteral("provider=%1 protocol=%2 model=%3 url=%4 timeoutMs=%5 maxTokens=%6 temperature=%7")
.arg(config.provider.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.provider.trimmed())
.arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.protocol.trimmed())
.arg(config.model.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.model.trimmed())
.arg(sanitizedUrlString(url))
.arg(config.timeoutMs)
.arg(config.maxTokens)
.arg(QString::number(config.temperature, 'f', 2));
}
QString errorMessageFromBody(const QByteArray &body)
{
if (body.trimmed().isEmpty())
{
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return trimmedResponseBody(body);
}
const QJsonObject root = document.object();
QStringList details;
const QJsonValue errorValue = root.value(QStringLiteral("error"));
if (errorValue.isObject())
{
const QJsonObject error = errorValue.toObject();
const QString message = error.value(QStringLiteral("message")).toString().trimmed();
const QString code = error.value(QStringLiteral("code")).toString().trimmed();
const QString type = error.value(QStringLiteral("type")).toString().trimmed();
if (!message.isEmpty())
{
details.append(message);
}
if (!code.isEmpty())
{
details.append(QStringLiteral("code=") + code);
}
if (!type.isEmpty() && type != code)
{
details.append(QStringLiteral("type=") + type);
}
}
else if (errorValue.isString())
{
const QString error = errorValue.toString().trimmed();
if (!error.isEmpty())
{
details.append(error);
}
}
const QString message = root.value(QStringLiteral("message")).toString().trimmed();
if (!message.isEmpty() && !details.contains(message))
{
details.append(message);
}
const QString requestId = root.value(QStringLiteral("request_id")).toString().trimmed();
if (!requestId.isEmpty())
{
details.append(QStringLiteral("request_id=") + requestId);
}
if (!details.isEmpty())
{
return details.join(QStringLiteral("; "));
}
return trimmedResponseBody(body);
}
}
OpenAICompatibleProvider::OpenAICompatibleProvider(const AIConfig &config) OpenAICompatibleProvider::OpenAICompatibleProvider(const AIConfig &config)
: m_config(config) : m_config(config)
{ {
@@ -239,7 +103,7 @@ void OpenAICompatibleProvider::sendChatRequestInternal(
const QJsonDocument document(buildPayload(request, stream)); const QJsonDocument document(buildPayload(request, stream));
const QByteArray payload = document.toJson(QJsonDocument::Compact); const QByteArray payload = document.toJson(QJsonDocument::Compact);
Logger::info(QStringLiteral("AI request started: %1 stream=%2 messageCount=%3 payloadBytes=%4") Logger::info(QStringLiteral("AI request started: %1 stream=%2 messageCount=%3 payloadBytes=%4")
.arg(diagnosticContext(m_config, url)) .arg(AIDiagnostics::diagnosticContext(m_config, url))
.arg(stream ? QStringLiteral("true") : QStringLiteral("false")) .arg(stream ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(request.messages.size())) .arg(QString::number(request.messages.size()))
.arg(QString::number(payload.size()))); .arg(QString::number(payload.size())));
@@ -311,7 +175,7 @@ void OpenAICompatibleProvider::cancel()
if (!reply.isNull()) if (!reply.isNull())
{ {
Logger::info(QStringLiteral("AI request canceled: %1") Logger::info(QStringLiteral("AI request canceled: %1")
.arg(diagnosticContext(m_config, reply->request().url()))); .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())));
} }
clearReply(); clearReply();
@@ -416,8 +280,8 @@ bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload)
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3") Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3")
.arg(diagnosticContext(m_config, requestUrl())) .arg(AIDiagnostics::diagnosticContext(m_config, requestUrl()))
.arg(oneLine(parseError.errorString())) .arg(AIDiagnostics::oneLine(parseError.errorString()))
.arg(QString::number(payload.size()))); .arg(QString::number(payload.size())));
return true; return true;
} }
@@ -425,7 +289,7 @@ bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload)
const QJsonObject root = document.object(); const QJsonObject root = document.object();
if (root.contains(QStringLiteral("error"))) if (root.contains(QStringLiteral("error")))
{ {
const QString bodyError = errorMessageFromBody(payload); const QString bodyError = AIDiagnostics::errorMessageFromBody(payload);
finishWithError(bodyError.isEmpty() ? QStringLiteral("AI stream returned an error.") : bodyError); finishWithError(bodyError.isEmpty() ? QStringLiteral("AI stream returned an error.") : bodyError);
return false; return false;
} }
@@ -458,13 +322,13 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
const QString bodyError = errorMessageFromBody(body); const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\"") Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(static_cast<int>(reply->error())) .arg(static_cast<int>(reply->error()))
.arg(oneLine(reply->errorString())) .arg(AIDiagnostics::oneLine(reply->errorString()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
if (!bodyError.isEmpty()) if (!bodyError.isEmpty())
{ {
@@ -478,11 +342,11 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" body=\"%4\"") Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" bodySummary=\"%4\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(parseError.errorString())) .arg(AIDiagnostics::oneLine(parseError.errorString()))
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus}; return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus};
} }
@@ -490,10 +354,10 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const QJsonArray choices = root.value(QStringLiteral("choices")).toArray(); const QJsonArray choices = root.value(QStringLiteral("choices")).toArray();
if (choices.isEmpty() || !choices.first().isObject()) if (choices.isEmpty() || !choices.first().isObject())
{ {
Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 body=\"%3\"") Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response has no choices."), httpStatus}; return {false, {}, QStringLiteral("AI response has no choices."), httpStatus};
} }
@@ -501,15 +365,15 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const QString content = message.value(QStringLiteral("content")).toString(); const QString content = message.value(QStringLiteral("content")).toString();
if (content.isEmpty()) if (content.isEmpty())
{ {
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 body=\"%3\"") Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body)))); .arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response content is empty."), httpStatus}; return {false, {}, QStringLiteral("AI response content is empty."), httpStatus};
} }
Logger::info(QStringLiteral("AI request completed: %1 httpStatus=%2 responseChars=%3 responseBytes=%4") Logger::info(QStringLiteral("AI request completed: %1 httpStatus=%2 responseChars=%3 responseBytes=%4")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(QString::number(content.size())) .arg(QString::number(content.size()))
.arg(QString::number(body.size()))); .arg(QString::number(body.size())));
@@ -525,13 +389,13 @@ ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *re
: QByteArray(); : QByteArray();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
const QString bodyError = errorMessageFromBody(diagnosticBody); const QString bodyError = AIDiagnostics::errorMessageFromBody(diagnosticBody);
Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\" streamedChars=%6") Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\" streamedChars=%6")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(static_cast<int>(reply->error())) .arg(static_cast<int>(reply->error()))
.arg(oneLine(reply->errorString())) .arg(AIDiagnostics::oneLine(reply->errorString()))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(trimmedResponseBody(diagnosticBody))) .arg(AIDiagnostics::responseBodySummary(diagnosticBody))
.arg(QString::number(m_streamedContent.size()))); .arg(QString::number(m_streamedContent.size())));
if (!bodyError.isEmpty()) if (!bodyError.isEmpty())
@@ -544,17 +408,17 @@ ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *re
if (m_streamedContent.isEmpty()) if (m_streamedContent.isEmpty())
{ {
Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 body=\"%5\"") Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 bodySummary=\"%5\"")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(m_streamBuffer.size())) .arg(QString::number(m_streamBuffer.size()))
.arg(oneLine(trimmedResponseBody(diagnosticBody)))); .arg(AIDiagnostics::responseBodySummary(diagnosticBody)));
return {false, {}, QStringLiteral("AI streaming response content is empty."), httpStatus}; return {false, {}, QStringLiteral("AI streaming response content is empty."), httpStatus};
} }
Logger::info(QStringLiteral("AI streaming request completed: %1 httpStatus=%2 streamDone=%3 responseChars=%4") Logger::info(QStringLiteral("AI streaming request completed: %1 httpStatus=%2 streamDone=%3 responseChars=%4")
.arg(diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(m_streamedContent.size()))); .arg(QString::number(m_streamedContent.size())));
@@ -567,9 +431,9 @@ void OpenAICompatibleProvider::finishWithError(const QString &message, int httpS
QPointer<QNetworkReply> reply = m_currentReply; QPointer<QNetworkReply> reply = m_currentReply;
const QUrl url = reply.isNull() ? requestUrl() : reply->request().url(); const QUrl url = reply.isNull() ? requestUrl() : reply->request().url();
Logger::warning(QStringLiteral("AI request finished with error: %1 httpStatus=%2 error=\"%3\"") Logger::warning(QStringLiteral("AI request finished with error: %1 httpStatus=%2 error=\"%3\"")
.arg(diagnosticContext(m_config, url)) .arg(AIDiagnostics::diagnosticContext(m_config, url))
.arg(httpStatus) .arg(httpStatus)
.arg(oneLine(message))); .arg(AIDiagnostics::safeTextSummary(message)));
clearReply(); clearReply();
+30
View File
@@ -25,6 +25,11 @@ bool AnimationClip::isValid() const
return fps > 0 && (!m_frames.isEmpty() || !m_framePaths.isEmpty()); return fps > 0 && (!m_frames.isEmpty() || !m_framePaths.isEmpty());
} }
bool AnimationClip::isLoaded() const
{
return !m_frames.isEmpty();
}
bool AnimationClip::ensureLoaded() bool AnimationClip::ensureLoaded()
{ {
if (m_frames.isEmpty()) if (m_frames.isEmpty())
@@ -35,6 +40,31 @@ bool AnimationClip::ensureLoaded()
return !m_frames.isEmpty(); return !m_frames.isEmpty();
} }
void AnimationClip::unloadFrames()
{
m_frames.clear();
m_frames.squeeze();
}
qint64 AnimationClip::estimatedMemoryBytes() const
{
qint64 totalBytes = 0;
for (const QPixmap &frame : m_frames)
{
totalBytes += static_cast<qint64>(frame.width())
* static_cast<qint64>(frame.height())
* static_cast<qint64>(frame.depth())
/ 8;
}
return totalBytes;
}
int AnimationClip::loadedFrameCount() const
{
return m_frames.size();
}
void AnimationClip::loadFrames() void AnimationClip::loadFrames()
{ {
if (!m_frames.isEmpty()) if (!m_frames.isEmpty())
+5
View File
@@ -7,6 +7,7 @@
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QVector> #include <QVector>
#include <QtGlobal>
class AnimationClip class AnimationClip
{ {
@@ -14,7 +15,11 @@ public:
static AnimationClip fromState(const CharacterState &state, const QSize &targetSize, bool loadFrames = true); static AnimationClip fromState(const CharacterState &state, const QSize &targetSize, bool loadFrames = true);
bool isValid() const; bool isValid() const;
bool isLoaded() const;
bool ensureLoaded(); bool ensureLoaded();
void unloadFrames();
qint64 estimatedMemoryBytes() const;
int loadedFrameCount() const;
const QPixmap &frameAt(int index) const; const QPixmap &frameAt(int index) const;
int frameCount() const; int frameCount() const;
+3 -1
View File
@@ -1,5 +1,7 @@
#include "CharacterPackageRepository.h" #include "CharacterPackageRepository.h"
#include "../util/ResourcePaths.h"
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
@@ -18,7 +20,7 @@ bool isValidCharacterId(const QString &characterId)
QString CharacterPackageRepository::charactersRootPath() QString CharacterPackageRepository::charactersRootPath()
{ {
return QDir::cleanPath(QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/resources/characters")); return ResourcePaths::charactersRootPath();
} }
QString CharacterPackageRepository::defaultCharacterId() QString CharacterPackageRepository::defaultCharacterId()
+3
View File
@@ -12,6 +12,9 @@ struct AppConfig
QString performanceMode = QStringLiteral("standard"); QString performanceMode = QStringLiteral("standard");
bool pauseWhenHidden = true; bool pauseWhenHidden = true;
bool enableLazyLoad = true; bool enableLazyLoad = true;
bool enableAnimationPrewarm = true;
int animationCacheLimitMb = 180;
bool unloadAnimationsWhenHidden = true;
int requestContextMessageLimit = 12; int requestContextMessageLimit = 12;
int memoryHistoryMessageLimit = 200; int memoryHistoryMessageLimit = 200;
bool saveConversationHistory = false; bool saveConversationHistory = false;
+42 -8
View File
@@ -2,6 +2,7 @@
#include "../util/Logger.h" #include "../util/Logger.h"
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
@@ -33,6 +34,9 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config)
performance.insert(QStringLiteral("mode"), config.performanceMode); performance.insert(QStringLiteral("mode"), config.performanceMode);
performance.insert(QStringLiteral("pauseWhenHidden"), config.pauseWhenHidden); performance.insert(QStringLiteral("pauseWhenHidden"), config.pauseWhenHidden);
performance.insert(QStringLiteral("enableLazyLoad"), config.enableLazyLoad); performance.insert(QStringLiteral("enableLazyLoad"), config.enableLazyLoad);
performance.insert(QStringLiteral("enableAnimationPrewarm"), config.enableAnimationPrewarm);
performance.insert(QStringLiteral("animationCacheLimitMb"), config.animationCacheLimitMb);
performance.insert(QStringLiteral("unloadAnimationsWhenHidden"), config.unloadAnimationsWhenHidden);
return performance; return performance;
} }
@@ -164,7 +168,7 @@ AppConfig ConfigManager::loadAppConfig() const
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
file.close(); file.close();
backupBrokenConfig(appConfigPath()); backupBrokenConfig(appConfigPath(), QStringLiteral("app config"));
Logger::warning(QStringLiteral("App config is broken; default config will be used.")); Logger::warning(QStringLiteral("App config is broken; default config will be used."));
return config; return config;
} }
@@ -205,6 +209,21 @@ AppConfig ConfigManager::loadAppConfig() const
config.enableLazyLoad = performance.value(QStringLiteral("enableLazyLoad")).toBool(config.enableLazyLoad); config.enableLazyLoad = performance.value(QStringLiteral("enableLazyLoad")).toBool(config.enableLazyLoad);
} }
if (performance.contains(QStringLiteral("enableAnimationPrewarm")))
{
config.enableAnimationPrewarm = performance.value(QStringLiteral("enableAnimationPrewarm")).toBool(config.enableAnimationPrewarm);
}
if (performance.contains(QStringLiteral("animationCacheLimitMb")))
{
config.animationCacheLimitMb = performance.value(QStringLiteral("animationCacheLimitMb")).toInt(config.animationCacheLimitMb);
}
if (performance.contains(QStringLiteral("unloadAnimationsWhenHidden")))
{
config.unloadAnimationsWhenHidden = performance.value(QStringLiteral("unloadAnimationsWhenHidden")).toBool(config.unloadAnimationsWhenHidden);
}
const QJsonObject chat = root.value(QStringLiteral("chat")).toObject(); const QJsonObject chat = root.value(QStringLiteral("chat")).toObject();
if (chat.contains(QStringLiteral("requestContextMessageLimit"))) if (chat.contains(QStringLiteral("requestContextMessageLimit")))
{ {
@@ -250,7 +269,7 @@ AIConfigStore ConfigManager::loadAIConfigStore() const
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
file.close(); file.close();
backupBrokenConfig(aiConfigPath()); backupBrokenConfig(aiConfigPath(), QStringLiteral("AI config"));
Logger::warning(QStringLiteral("AI config is broken; default config will be used.")); Logger::warning(QStringLiteral("AI config is broken; default config will be used."));
return store; return store;
} }
@@ -387,7 +406,7 @@ QString ConfigManager::configDirectoryPath() const
return QDir::currentPath(); return QDir::currentPath();
} }
void ConfigManager::backupBrokenConfig(const QString &filePath) const void ConfigManager::backupBrokenConfig(const QString &filePath, const QString &configName) const
{ {
QFile file(filePath); QFile file(filePath);
if (!file.exists()) if (!file.exists())
@@ -396,12 +415,27 @@ void ConfigManager::backupBrokenConfig(const QString &filePath) const
} }
const QFileInfo fileInfo(filePath); const QFileInfo fileInfo(filePath);
const QString backupPath = fileInfo.dir().filePath(fileInfo.completeBaseName() + QStringLiteral(".broken.json")); const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss"));
if (QFile::exists(backupPath)) QString backupPath = fileInfo.dir().filePath(
fileInfo.completeBaseName() + QStringLiteral(".broken.") + timestamp + QStringLiteral(".json"));
int suffix = 1;
while (QFile::exists(backupPath))
{ {
QFile::remove(backupPath); backupPath = fileInfo.dir().filePath(
fileInfo.completeBaseName()
+ QStringLiteral(".broken.")
+ timestamp
+ QStringLiteral("-")
+ QString::number(suffix)
+ QStringLiteral(".json"));
++suffix;
} }
file.rename(backupPath); if (file.rename(backupPath))
Logger::warning(QStringLiteral("Broken app config was backed up.")); {
Logger::warning(QStringLiteral("Broken %1 was backed up: %2").arg(configName, backupPath));
return;
}
Logger::warning(QStringLiteral("Failed to back up broken %1: %2").arg(configName, filePath));
} }
+1 -1
View File
@@ -22,5 +22,5 @@ public:
private: private:
QString configDirectoryPath() const; QString configDirectoryPath() const;
void backupBrokenConfig(const QString &filePath) const; void backupBrokenConfig(const QString &filePath, const QString &configName) const;
}; };
+8
View File
@@ -2,16 +2,24 @@
#include "../character/CharacterPackageRepository.h" #include "../character/CharacterPackageRepository.h"
#include "../ui/PetWindow.h" #include "../ui/PetWindow.h"
#include "../util/ResourcePaths.h"
#include <QAction> #include <QAction>
#include <QApplication> #include <QApplication>
#include <QIcon> #include <QIcon>
#include <QPixmap> #include <QPixmap>
#include <QString>
namespace namespace
{ {
QIcon loadTrayIcon() QIcon loadTrayIcon()
{ {
const QIcon appIcon(ResourcePaths::appIconPath());
if (!appIcon.isNull())
{
return appIcon;
}
const QPixmap pixmap(CharacterPackageRepository::defaultPreviewPath()); const QPixmap pixmap(CharacterPackageRepository::defaultPreviewPath());
if (!pixmap.isNull()) if (!pixmap.isNull())
{ {
+8
View File
@@ -80,6 +80,14 @@ ChatBubble::ChatBubble(QWidget *parent)
}); });
} }
ChatBubble::~ChatBubble()
{
if (qApp != nullptr)
{
qApp->removeEventFilter(this);
}
}
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom) void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom)
{ {
m_dismissOnExternalInteraction = false; m_dismissOnExternalInteraction = false;
+1
View File
@@ -10,6 +10,7 @@ class ChatBubble : public QWidget
{ {
public: public:
explicit ChatBubble(QWidget *parent = nullptr); explicit ChatBubble(QWidget *parent = nullptr);
~ChatBubble() override;
void showMessage( void showMessage(
const QString &message, const QString &message,
+380
View File
@@ -19,6 +19,7 @@
#include <QDialog> #include <QDialog>
#include <QGuiApplication> #include <QGuiApplication>
#include <QHideEvent> #include <QHideEvent>
#include <QList>
#include <QMenu> #include <QMenu>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPixmap> #include <QPixmap>
@@ -27,10 +28,12 @@
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QScreen> #include <QScreen>
#include <QSet> #include <QSet>
#include <QShowEvent>
#include <QStringList> #include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QtGlobal> #include <QtGlobal>
#include <algorithm>
#include <memory> #include <memory>
namespace namespace
@@ -42,6 +45,9 @@ constexpr int MinAnimationTargetSide = 32;
constexpr int MaxAnimationTargetSide = 2048; constexpr int MaxAnimationTargetSide = 2048;
constexpr int LowPowerFpsCap = 6; constexpr int LowPowerFpsCap = 6;
constexpr int ChatFinishedReturnDelayMs = 1500; constexpr int ChatFinishedReturnDelayMs = 1500;
constexpr int StandardPrewarmIntervalMs = 800;
constexpr int LowPowerPrewarmIntervalMs = 1500;
constexpr qint64 BytesPerMegabyte = 1024 * 1024;
int boundedAnimationTargetSide(double sideLength) int boundedAnimationTargetSide(double sideLength)
{ {
@@ -58,6 +64,11 @@ int evenBoundedHistoryLimit(int value, int minimum, int maximum)
return boundedValue - (boundedValue % 2); return boundedValue - (boundedValue % 2);
} }
QString megabytesText(qint64 bytes)
{
return QString::number(static_cast<double>(bytes) / static_cast<double>(BytesPerMegabyte), 'f', 1);
}
AppConfig normalizedAppConfig(AppConfig config) AppConfig normalizedAppConfig(AppConfig config)
{ {
config.scale = qBound(0.5, config.scale, 2.0); config.scale = qBound(0.5, config.scale, 2.0);
@@ -66,6 +77,7 @@ AppConfig normalizedAppConfig(AppConfig config)
{ {
config.performanceMode = QStringLiteral("standard"); config.performanceMode = QStringLiteral("standard");
} }
config.animationCacheLimitMb = qBound(64, config.animationCacheLimitMb, 1024);
config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200); config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200);
config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000); config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000);
config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000); config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000);
@@ -134,6 +146,11 @@ PetWindow::PetWindow(QWidget *parent)
flushStreamingBubble(false); flushStreamingBubble(false);
}); });
m_animationPrewarmTimer.setSingleShot(true);
connect(&m_animationPrewarmTimer, &QTimer::timeout, this, [this]() {
processAnimationPrewarm();
});
QPointer<PetWindow> window(this); QPointer<PetWindow> window(this);
m_chatInputDialog->setSubmitCallback([window](const QString &message) { m_chatInputDialog->setSubmitCallback([window](const QString &message) {
return !window.isNull() && window->submitChatMessage(message); return !window.isNull() && window->submitChatMessage(message);
@@ -153,6 +170,10 @@ void PetWindow::applyAppConfig(const AppConfig &config)
const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale) const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|| m_appConfig.performanceMode != normalizedConfig.performanceMode || m_appConfig.performanceMode != normalizedConfig.performanceMode
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; || 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 const bool loadPersistedHistory = !m_appConfig.saveConversationHistory
&& normalizedConfig.saveConversationHistory; && normalizedConfig.saveConversationHistory;
@@ -182,6 +203,21 @@ void PetWindow::applyAppConfig(const AppConfig &config)
playResolvedState(m_stateMachine.requestState(nextState), false); 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 PetWindow::currentAppConfig() const
@@ -203,6 +239,7 @@ void PetWindow::pauseAnimation()
m_returnToIdleAfterResume = m_behaviorReturnTimer.isActive(); m_returnToIdleAfterResume = m_behaviorReturnTimer.isActive();
m_idleBehaviorTimer.stop(); m_idleBehaviorTimer.stop();
m_behaviorReturnTimer.stop(); m_behaviorReturnTimer.stop();
stopAnimationPrewarm();
m_frameAnimator.pause(); m_frameAnimator.pause();
} }
@@ -220,6 +257,7 @@ void PetWindow::resumeAnimation()
} }
m_returnToIdleAfterResume = false; m_returnToIdleAfterResume = false;
scheduleAnimationPrewarm();
} }
void PetWindow::showBubbleMessage(const QString &message) void PetWindow::showBubbleMessage(const QString &message)
@@ -381,6 +419,7 @@ bool PetWindow::submitChatMessage(const QString &message)
return false; return false;
} }
stopAnimationPrewarm();
playState(QStringLiteral("think"), false); playState(QStringLiteral("think"), false);
m_streamingAssistantText.clear(); m_streamingAssistantText.clear();
m_streamBubbleUpdateTimer.stop(); m_streamBubbleUpdateTimer.stop();
@@ -617,6 +656,7 @@ void PetWindow::finishStreamingChat()
{ {
m_streamingChatActive = false; m_streamingChatActive = false;
m_streamingTalkStarted = false; m_streamingTalkStarted = false;
scheduleAnimationPrewarm();
} }
void PetWindow::cancelStreamingChat() void PetWindow::cancelStreamingChat()
@@ -625,6 +665,7 @@ void PetWindow::cancelStreamingChat()
m_streamingAssistantText.clear(); m_streamingAssistantText.clear();
m_streamingChatActive = false; m_streamingChatActive = false;
m_streamingTalkStarted = false; m_streamingTalkStarted = false;
scheduleAnimationPrewarm();
} }
void PetWindow::resetBubbleAutoHideTimer() void PetWindow::resetBubbleAutoHideTimer()
@@ -643,6 +684,7 @@ QPoint PetWindow::chatInputAnchorPosition() const
void PetWindow::hideEvent(QHideEvent *event) void PetWindow::hideEvent(QHideEvent *event)
{ {
m_streamBubbleUpdateTimer.stop(); m_streamBubbleUpdateTimer.stop();
stopAnimationPrewarm();
if (m_chatBubble) if (m_chatBubble)
{ {
m_chatBubble->hideBubble(); m_chatBubble->hideBubble();
@@ -656,9 +698,20 @@ void PetWindow::hideEvent(QHideEvent *event)
m_chatHistoryPanel->hide(); m_chatHistoryPanel->hide();
} }
if (isAnimationCacheManagementEnabled() && m_appConfig.unloadAnimationsWhenHidden)
{
unloadNonProtectedAnimationCache(QStringLiteral("window hidden"));
}
QWidget::hideEvent(event); QWidget::hideEvent(event);
} }
void PetWindow::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
scheduleAnimationPrewarm();
}
void PetWindow::mouseDoubleClickEvent(QMouseEvent *event) void PetWindow::mouseDoubleClickEvent(QMouseEvent *event)
{ {
resetBubbleAutoHideTimer(); resetBubbleAutoHideTimer();
@@ -702,6 +755,7 @@ void PetWindow::mousePressEvent(QMouseEvent *event)
{ {
m_dragging = true; m_dragging = true;
m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft(); m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft();
stopAnimationPrewarm();
playResolvedState(m_stateMachine.beginDrag(), false); playResolvedState(m_stateMachine.beginDrag(), false);
event->accept(); event->accept();
return; return;
@@ -718,6 +772,7 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
{ {
m_dragging = false; m_dragging = false;
playResolvedState(m_stateMachine.endDrag(), false); playResolvedState(m_stateMachine.endDrag(), false);
scheduleAnimationPrewarm();
event->accept(); event->accept();
return; return;
} }
@@ -747,6 +802,11 @@ void PetWindow::loadInitialImage()
void PetWindow::buildAnimationClips() void PetWindow::buildAnimationClips()
{ {
stopAnimationPrewarm();
m_animationPrewarmQueue.clear();
m_animationPrewarmAttemptedStates.clear();
m_clipLastAccessSerial.clear();
m_clipAccessSerial = 0;
m_clips.clear(); m_clips.clear();
QSet<QString> availableStates; QSet<QString> availableStates;
@@ -849,6 +909,7 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow)
if (m_frameAnimator.currentStateName() == stateName && m_frameAnimator.isPlaying()) if (m_frameAnimator.currentStateName() == stateName && m_frameAnimator.isPlaying())
{ {
noteAnimationClipAccess(stateName);
return; return;
} }
@@ -859,11 +920,22 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow)
} }
AnimationClip *clip = &clipIterator.value(); AnimationClip *clip = &clipIterator.value();
const bool wasLoaded = clip->isLoaded();
if (!clip->ensureLoaded()) if (!clip->ensureLoaded())
{ {
Logger::warning(QStringLiteral("Animation state failed to load: state=%1").arg(stateName));
return; 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_idleBehaviorTimer.stop();
m_behaviorReturnTimer.stop(); m_behaviorReturnTimer.stop();
m_centerNextFrame = centerWindow; m_centerNextFrame = centerWindow;
@@ -877,6 +949,9 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow)
{ {
m_behaviorReturnTimer.start(4000); m_behaviorReturnTimer.start(4000);
} }
trimAnimationCache(QStringLiteral("state played"));
scheduleAnimationPrewarm();
} }
QSize PetWindow::animationTargetSize() const QSize PetWindow::animationTargetSize() const
@@ -903,6 +978,311 @@ bool PetWindow::isLowPowerMode() const
return m_appConfig.performanceMode == QStringLiteral("low-power"); return m_appConfig.performanceMode == QStringLiteral("low-power");
} }
bool PetWindow::isAnimationCacheManagementEnabled() const
{
return m_appConfig.enableLazyLoad;
}
void PetWindow::rebuildAnimationPrewarmQueue()
{
m_animationPrewarmQueue.clear();
if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm)
{
return;
}
const auto appendStateIfNeeded = [this](const QString &stateName) {
if (stateName == QStringLiteral("idle")
|| m_animationPrewarmQueue.contains(stateName)
|| m_animationPrewarmAttemptedStates.contains(stateName))
{
return;
}
const auto iterator = m_clips.constFind(stateName);
if (iterator != m_clips.constEnd() && !iterator.value().isLoaded())
{
m_animationPrewarmQueue.append(stateName);
}
};
appendStateIfNeeded(QStringLiteral("drag"));
appendStateIfNeeded(QStringLiteral("think"));
appendStateIfNeeded(QStringLiteral("talk"));
QStringList stateNames = m_clips.keys();
stateNames.sort();
for (const QString &stateName : stateNames)
{
appendStateIfNeeded(stateName);
}
}
void PetWindow::scheduleAnimationPrewarm()
{
if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm)
{
stopAnimationPrewarm();
return;
}
if (!isVisible() || m_dragging || m_streamingChatActive || hasActiveAIRequest())
{
stopAnimationPrewarm();
return;
}
if (m_animationPrewarmQueue.isEmpty())
{
rebuildAnimationPrewarmQueue();
}
if (m_animationPrewarmQueue.isEmpty() || m_animationPrewarmTimer.isActive())
{
return;
}
m_animationPrewarmTimer.start(isLowPowerMode() ? LowPowerPrewarmIntervalMs : StandardPrewarmIntervalMs);
}
void PetWindow::stopAnimationPrewarm()
{
m_animationPrewarmTimer.stop();
}
void PetWindow::processAnimationPrewarm()
{
m_animationPrewarmTimer.stop();
if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm)
{
return;
}
if (!isVisible() || m_dragging || m_streamingChatActive || hasActiveAIRequest())
{
return;
}
while (!m_animationPrewarmQueue.isEmpty())
{
const QString stateName = m_animationPrewarmQueue.takeFirst();
m_animationPrewarmAttemptedStates.insert(stateName);
auto clipIterator = m_clips.find(stateName);
if (clipIterator == m_clips.end() || clipIterator.value().isLoaded())
{
continue;
}
AnimationClip &clip = clipIterator.value();
if (!clip.ensureLoaded())
{
Logger::warning(QStringLiteral("Animation state prewarm failed: state=%1").arg(stateName));
continue;
}
noteAnimationClipAccess(stateName);
Logger::info(QStringLiteral("Animation state prewarmed: state=%1 frames=%2 cacheMb=%3")
.arg(stateName)
.arg(QString::number(clip.loadedFrameCount()))
.arg(megabytesText(clip.estimatedMemoryBytes())));
trimAnimationCache(QStringLiteral("prewarm"));
break;
}
scheduleAnimationPrewarm();
}
void PetWindow::noteAnimationClipAccess(const QString &stateName)
{
if (!m_clips.contains(stateName))
{
return;
}
++m_clipAccessSerial;
m_clipLastAccessSerial.insert(stateName, m_clipAccessSerial);
}
qint64 PetWindow::animationCacheLimitBytes() const
{
return static_cast<qint64>(m_appConfig.animationCacheLimitMb) * BytesPerMegabyte;
}
qint64 PetWindow::loadedAnimationCacheBytes() const
{
qint64 totalBytes = 0;
for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator)
{
totalBytes += iterator.value().estimatedMemoryBytes();
}
return totalBytes;
}
int PetWindow::loadedAnimationClipCount() const
{
int count = 0;
for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator)
{
if (iterator.value().isLoaded())
{
++count;
}
}
return count;
}
QSet<QString> PetWindow::protectedAnimationStates() const
{
QSet<QString> states;
states.insert(QStringLiteral("idle"));
const QString animatorState = m_frameAnimator.currentStateName();
if (!animatorState.isEmpty())
{
states.insert(animatorState);
}
const QString stateMachineState = m_stateMachine.currentState();
if (!stateMachineState.isEmpty())
{
states.insert(stateMachineState);
}
if (m_streamingChatActive || hasActiveAIRequest())
{
states.insert(QStringLiteral("think"));
states.insert(QStringLiteral("talk"));
}
if (m_dragging)
{
states.insert(QStringLiteral("drag"));
}
return states;
}
void PetWindow::trimAnimationCache(const QString &reason)
{
if (!isAnimationCacheManagementEnabled())
{
return;
}
qint64 totalBytes = loadedAnimationCacheBytes();
const qint64 limitBytes = animationCacheLimitBytes();
if (totalBytes <= limitBytes)
{
return;
}
struct UnloadCandidate
{
QString stateName;
qint64 lastAccessSerial = 0;
};
const QSet<QString> protectedStates = protectedAnimationStates();
QList<UnloadCandidate> candidates;
QStringList protectedLoadedStates;
for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator)
{
if (!iterator.value().isLoaded())
{
continue;
}
if (protectedStates.contains(iterator.key()))
{
protectedLoadedStates.append(iterator.key());
continue;
}
candidates.append({iterator.key(), m_clipLastAccessSerial.value(iterator.key(), 0)});
}
std::sort(candidates.begin(), candidates.end(), [](const UnloadCandidate &left, const UnloadCandidate &right) {
if (left.lastAccessSerial == right.lastAccessSerial)
{
return left.stateName < right.stateName;
}
return left.lastAccessSerial < right.lastAccessSerial;
});
for (const UnloadCandidate &candidate : candidates)
{
if (totalBytes <= limitBytes)
{
break;
}
auto clipIterator = m_clips.find(candidate.stateName);
if (clipIterator == m_clips.end() || !clipIterator.value().isLoaded())
{
continue;
}
const qint64 freedBytes = clipIterator.value().estimatedMemoryBytes();
clipIterator.value().unloadFrames();
m_clipLastAccessSerial.remove(candidate.stateName);
totalBytes -= freedBytes;
Logger::info(QStringLiteral("Animation state unloaded: state=%1 reason=%2 freedMb=%3 cacheMb=%4 loadedStates=%5")
.arg(candidate.stateName)
.arg(reason)
.arg(megabytesText(freedBytes))
.arg(megabytesText(totalBytes))
.arg(QString::number(loadedAnimationClipCount())));
}
if (totalBytes > limitBytes)
{
protectedLoadedStates.sort();
Logger::warning(QStringLiteral("Animation cache remains over limit: reason=%1 cacheMb=%2 limitMb=%3 protectedStates=%4")
.arg(reason)
.arg(megabytesText(totalBytes))
.arg(QString::number(m_appConfig.animationCacheLimitMb))
.arg(protectedLoadedStates.join(QStringLiteral(","))));
}
}
void PetWindow::unloadNonProtectedAnimationCache(const QString &reason)
{
if (!isAnimationCacheManagementEnabled())
{
return;
}
const QSet<QString> protectedStates = protectedAnimationStates();
for (auto iterator = m_clips.begin(); iterator != m_clips.end(); ++iterator)
{
if (!iterator.value().isLoaded())
{
continue;
}
if (protectedStates.contains(iterator.key()))
{
Logger::info(QStringLiteral("Animation state kept loaded: state=%1 reason=%2")
.arg(iterator.key())
.arg(reason));
continue;
}
const qint64 freedBytes = iterator.value().estimatedMemoryBytes();
iterator.value().unloadFrames();
m_clipLastAccessSerial.remove(iterator.key());
Logger::info(QStringLiteral("Animation state unloaded: state=%1 reason=%2 freedMb=%3 cacheMb=%4 loadedStates=%5")
.arg(iterator.key())
.arg(reason)
.arg(megabytesText(freedBytes))
.arg(megabytesText(loadedAnimationCacheBytes()))
.arg(QString::number(loadedAnimationClipCount())));
}
}
void PetWindow::scheduleIdleBehavior() void PetWindow::scheduleIdleBehavior()
{ {
if (!m_clips.contains(QStringLiteral("idle"))) if (!m_clips.contains(QStringLiteral("idle")))
+22
View File
@@ -8,8 +8,11 @@
#include <QMap> #include <QMap>
#include <QPoint> #include <QPoint>
#include <QSet>
#include <QStringList>
#include <QTimer> #include <QTimer>
#include <QWidget> #include <QWidget>
#include <QtGlobal>
#include <memory> #include <memory>
@@ -17,6 +20,7 @@ class QMenu;
class QHideEvent; class QHideEvent;
class QMoveEvent; class QMoveEvent;
class QPixmap; class QPixmap;
class QShowEvent;
class ChatBubble; class ChatBubble;
class ChatHistoryPanel; class ChatHistoryPanel;
class ChatInputDialog; class ChatInputDialog;
@@ -41,6 +45,7 @@ public:
protected: protected:
void contextMenuEvent(QContextMenuEvent *event) override; void contextMenuEvent(QContextMenuEvent *event) override;
void hideEvent(QHideEvent *event) override; void hideEvent(QHideEvent *event) override;
void showEvent(QShowEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override; void mouseDoubleClickEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override;
void mousePressEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override;
@@ -75,6 +80,18 @@ private:
QSize animationTargetSize() const; QSize animationTargetSize() const;
int effectiveAnimationFps(int fps) const; int effectiveAnimationFps(int fps) const;
bool isLowPowerMode() const; bool isLowPowerMode() const;
bool isAnimationCacheManagementEnabled() const;
void rebuildAnimationPrewarmQueue();
void scheduleAnimationPrewarm();
void stopAnimationPrewarm();
void processAnimationPrewarm();
void noteAnimationClipAccess(const QString &stateName);
qint64 animationCacheLimitBytes() const;
qint64 loadedAnimationCacheBytes() const;
int loadedAnimationClipCount() const;
QSet<QString> protectedAnimationStates() const;
void trimAnimationCache(const QString &reason);
void unloadNonProtectedAnimationCache(const QString &reason);
void scheduleIdleBehavior(); void scheduleIdleBehavior();
void playIdleBehavior(); void playIdleBehavior();
void returnToIdleFromBehavior(); void returnToIdleFromBehavior();
@@ -92,13 +109,18 @@ private:
QTimer m_idleBehaviorTimer; QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer; QTimer m_behaviorReturnTimer;
QTimer m_streamBubbleUpdateTimer; QTimer m_streamBubbleUpdateTimer;
QTimer m_animationPrewarmTimer;
AppConfig m_appConfig; AppConfig m_appConfig;
CharacterPackage m_characterPackage; CharacterPackage m_characterPackage;
QMap<QString, AnimationClip> m_clips; QMap<QString, AnimationClip> m_clips;
QMap<QString, qint64> m_clipLastAccessSerial;
QSet<QString> m_animationPrewarmAttemptedStates;
FrameAnimator m_frameAnimator; FrameAnimator m_frameAnimator;
PetStateMachine m_stateMachine; PetStateMachine m_stateMachine;
QPoint m_dragOffset; QPoint m_dragOffset;
QString m_streamingAssistantText; QString m_streamingAssistantText;
QStringList m_animationPrewarmQueue;
qint64 m_clipAccessSerial = 0;
bool m_dragging; bool m_dragging;
bool m_alwaysOnTop; bool m_alwaysOnTop;
bool m_centerNextFrame; bool m_centerNextFrame;
+111 -11
View File
@@ -81,6 +81,9 @@ SettingsDialog::SettingsDialog(
, m_performanceModeComboBox(new QComboBox(this)) , m_performanceModeComboBox(new QComboBox(this))
, m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this)) , m_pauseWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏到托盘时暂停动画"), this))
, m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this)) , m_enableLazyLoadCheckBox(new QCheckBox(QStringLiteral("启用动画懒加载"), this))
, m_enableAnimationPrewarmCheckBox(new QCheckBox(QStringLiteral("启动后预热常用动画"), this))
, m_animationCacheLimitSpinBox(new QSpinBox(this))
, m_unloadAnimationsWhenHiddenCheckBox(new QCheckBox(QStringLiteral("隐藏时释放动画缓存"), this))
, m_requestContextMessageLimitSpinBox(new QSpinBox(this)) , m_requestContextMessageLimitSpinBox(new QSpinBox(this))
, m_memoryHistoryMessageLimitSpinBox(new QSpinBox(this)) , m_memoryHistoryMessageLimitSpinBox(new QSpinBox(this))
, m_saveConversationHistoryCheckBox(new QCheckBox(QStringLiteral("保存聊天记录到本地"), this)) , m_saveConversationHistoryCheckBox(new QCheckBox(QStringLiteral("保存聊天记录到本地"), this))
@@ -135,6 +138,12 @@ SettingsDialog::SettingsDialog(
m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden); m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden);
m_enableLazyLoadCheckBox->setChecked(m_appConfig.enableLazyLoad); m_enableLazyLoadCheckBox->setChecked(m_appConfig.enableLazyLoad);
m_enableAnimationPrewarmCheckBox->setChecked(m_appConfig.enableAnimationPrewarm);
m_animationCacheLimitSpinBox->setRange(64, 1024);
m_animationCacheLimitSpinBox->setSingleStep(16);
m_animationCacheLimitSpinBox->setSuffix(QStringLiteral(" MB"));
m_animationCacheLimitSpinBox->setValue(qBound(64, m_appConfig.animationCacheLimitMb, 1024));
m_unloadAnimationsWhenHiddenCheckBox->setChecked(m_appConfig.unloadAnimationsWhenHidden);
m_requestContextMessageLimitSpinBox->setRange(0, 200); m_requestContextMessageLimitSpinBox->setRange(0, 200);
m_requestContextMessageLimitSpinBox->setValue(qBound(0, m_appConfig.requestContextMessageLimit, 200)); m_requestContextMessageLimitSpinBox->setValue(qBound(0, m_appConfig.requestContextMessageLimit, 200));
@@ -217,6 +226,9 @@ SettingsDialog::SettingsDialog(
appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox); appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox);
appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox); appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox);
appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox); appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox);
appFormLayout->addRow(QString(), m_enableAnimationPrewarmCheckBox);
appFormLayout->addRow(QStringLiteral("动画缓存上限"), m_animationCacheLimitSpinBox);
appFormLayout->addRow(QString(), m_unloadAnimationsWhenHiddenCheckBox);
auto *appPageLayout = new QVBoxLayout(); auto *appPageLayout = new QVBoxLayout();
appPageLayout->setContentsMargins(24, 24, 24, 24); appPageLayout->setContentsMargins(24, 24, 24, 24);
@@ -332,7 +344,7 @@ SettingsDialog::SettingsDialog(
contentLayout->addWidget(pageStack, 1); contentLayout->addWidget(pageStack, 1);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this);
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this); auto *layout = new QVBoxLayout(this);
@@ -381,6 +393,13 @@ SettingsDialog::SettingsDialog(
connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() { connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() {
testConnection(); testConnection();
}); });
const auto updateAnimationCacheControls = [this](bool enabled) {
m_enableAnimationPrewarmCheckBox->setEnabled(enabled);
m_animationCacheLimitSpinBox->setEnabled(enabled);
m_unloadAnimationsWhenHiddenCheckBox->setEnabled(enabled);
};
updateAnimationCacheControls(m_enableLazyLoadCheckBox->isChecked());
connect(m_enableLazyLoadCheckBox, &QCheckBox::toggled, this, updateAnimationCacheControls);
connect(m_saveConversationHistoryCheckBox, &QCheckBox::toggled, m_savedHistoryMessageLimitSpinBox, &QSpinBox::setEnabled); connect(m_saveConversationHistoryCheckBox, &QCheckBox::toggled, m_savedHistoryMessageLimitSpinBox, &QSpinBox::setEnabled);
connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() { connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() {
this->clearConversationHistory(); this->clearConversationHistory();
@@ -397,13 +416,19 @@ SettingsDialog::~SettingsDialog()
AIConfigStore SettingsDialog::aiConfigStore() const AIConfigStore SettingsDialog::aiConfigStore() const
{ {
AIConfigStore store = m_configStore; if (m_hasAcceptedConfigStore)
store.providers.remove(QStringLiteral("claude")); {
store.providers.remove(QStringLiteral("calude")); return m_acceptedConfigStore;
const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString()); }
store.activeProvider = provider;
store.providers.insert(provider, configFromForm(provider)); AIConfigStore store;
return store; QString errorMessage;
if (buildAIConfigStore(&store, &errorMessage))
{
return store;
}
return m_configStore;
} }
AppConfig SettingsDialog::appConfig() const AppConfig SettingsDialog::appConfig() const
@@ -417,6 +442,9 @@ AppConfig SettingsDialog::appConfig() const
} }
config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->isChecked(); config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->isChecked();
config.enableLazyLoad = m_enableLazyLoadCheckBox->isChecked(); config.enableLazyLoad = m_enableLazyLoadCheckBox->isChecked();
config.enableAnimationPrewarm = m_enableAnimationPrewarmCheckBox->isChecked();
config.animationCacheLimitMb = m_animationCacheLimitSpinBox->value();
config.unloadAnimationsWhenHidden = m_unloadAnimationsWhenHiddenCheckBox->isChecked();
config.requestContextMessageLimit = m_requestContextMessageLimitSpinBox->value(); config.requestContextMessageLimit = m_requestContextMessageLimitSpinBox->value();
config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value(); config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value();
config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked(); config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked();
@@ -424,6 +452,61 @@ AppConfig SettingsDialog::appConfig() const
return config; return config;
} }
void SettingsDialog::accept()
{
if (!SecretStore::isEncryptionAvailable()
&& !m_allowPlainApiKeyCheckBox->isChecked()
&& !m_apiKeyEdit->text().isEmpty())
{
QMessageBox::information(
this,
QStringLiteral("API Key 未保存"),
QStringLiteral("当前平台不支持内置加密,且未允许明文保存。API Key 不会写入配置文件。"));
}
AIConfigStore store;
QString errorMessage;
if (!buildAIConfigStore(&store, &errorMessage))
{
QMessageBox::warning(this, QStringLiteral("设置未保存"), errorMessage);
setTestStatus(errorMessage, QStringLiteral("error"));
return;
}
m_acceptedConfigStore = store;
m_hasAcceptedConfigStore = true;
QDialog::accept();
}
bool SettingsDialog::buildAIConfigStore(AIConfigStore *store, QString *errorMessage) const
{
if (store == nullptr)
{
return false;
}
AIConfigStore result = m_configStore;
result.providers.remove(QStringLiteral("claude"));
result.providers.remove(QStringLiteral("calude"));
const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString());
QString configError;
const AIConfig currentConfig = configFromForm(provider, &configError);
if (!configError.isEmpty())
{
if (errorMessage != nullptr)
{
*errorMessage = configError;
}
return false;
}
result.activeProvider = provider;
result.providers.insert(provider, currentConfig);
*store = result;
return true;
}
void SettingsDialog::cacheCurrentProvider() void SettingsDialog::cacheCurrentProvider()
{ {
if (m_currentProvider.isEmpty()) if (m_currentProvider.isEmpty())
@@ -431,7 +514,15 @@ void SettingsDialog::cacheCurrentProvider()
return; return;
} }
m_configStore.providers.insert(m_currentProvider, configFromForm(m_currentProvider)); QString errorMessage;
const AIConfig config = configFromForm(m_currentProvider, &errorMessage);
if (!errorMessage.isEmpty())
{
setTestStatus(errorMessage, QStringLiteral("error"));
return;
}
m_configStore.providers.insert(m_currentProvider, config);
} }
void SettingsDialog::loadProviderConfig(const QString &provider) void SettingsDialog::loadProviderConfig(const QString &provider)
@@ -469,10 +560,13 @@ void SettingsDialog::switchProvider(const QString &provider)
m_currentProvider = normalizedProvider; m_currentProvider = normalizedProvider;
} }
AIConfig SettingsDialog::configFromForm(const QString &provider) const AIConfig SettingsDialog::configFromForm(const QString &provider, QString *errorMessage) const
{ {
const QString normalizedProvider = normalizedProviderName(provider); const QString normalizedProvider = normalizedProviderName(provider);
AIConfig config = m_configStore.providers.value(normalizedProvider, defaultAIConfigForProvider(normalizedProvider)); AIConfig config = m_configStore.providers.value(normalizedProvider, defaultAIConfigForProvider(normalizedProvider));
const QString previousApiKeyStorage = config.apiKeyStorage;
const QString previousApiKeyEncrypted = config.apiKeyEncrypted;
const QString previousApiKey = config.apiKey;
config.provider = normalizedProvider; config.provider = normalizedProvider;
config.protocol = defaultAIConfigForProvider(normalizedProvider).protocol; config.protocol = defaultAIConfigForProvider(normalizedProvider).protocol;
config.baseUrl = m_baseUrlEdit->text().trimmed(); config.baseUrl = m_baseUrlEdit->text().trimmed();
@@ -501,7 +595,13 @@ AIConfig SettingsDialog::configFromForm(const QString &provider) const
} }
else else
{ {
config.apiKeyStorage = QStringLiteral("none"); config.apiKeyStorage = previousApiKeyStorage;
config.apiKeyEncrypted = previousApiKeyEncrypted;
config.apiKey = previousApiKey;
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("API Key 加密失败:") + result.errorMessage;
}
} }
} }
else if (config.allowPlainApiKey) else if (config.allowPlainApiKey)
+10 -1
View File
@@ -31,11 +31,15 @@ public:
AIConfigStore aiConfigStore() const; AIConfigStore aiConfigStore() const;
AppConfig appConfig() const; AppConfig appConfig() const;
protected:
void accept() override;
private: private:
void cacheCurrentProvider(); void cacheCurrentProvider();
void loadProviderConfig(const QString &provider); void loadProviderConfig(const QString &provider);
void switchProvider(const QString &provider); void switchProvider(const QString &provider);
AIConfig configFromForm(const QString &provider) const; bool buildAIConfigStore(AIConfigStore *store, QString *errorMessage) const;
AIConfig configFromForm(const QString &provider, QString *errorMessage = nullptr) const;
AIConfig runtimeConfigFromForm(const QString &provider) const; AIConfig runtimeConfigFromForm(const QString &provider) const;
QString decryptedApiKey(const AIConfig &config) const; QString decryptedApiKey(const AIConfig &config) const;
void testConnection(); void testConnection();
@@ -57,6 +61,9 @@ private:
QComboBox *m_performanceModeComboBox = nullptr; QComboBox *m_performanceModeComboBox = nullptr;
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
QCheckBox *m_enableLazyLoadCheckBox = nullptr; QCheckBox *m_enableLazyLoadCheckBox = nullptr;
QCheckBox *m_enableAnimationPrewarmCheckBox = nullptr;
QSpinBox *m_animationCacheLimitSpinBox = nullptr;
QCheckBox *m_unloadAnimationsWhenHiddenCheckBox = nullptr;
QSpinBox *m_requestContextMessageLimitSpinBox = nullptr; QSpinBox *m_requestContextMessageLimitSpinBox = nullptr;
QSpinBox *m_memoryHistoryMessageLimitSpinBox = nullptr; QSpinBox *m_memoryHistoryMessageLimitSpinBox = nullptr;
QCheckBox *m_saveConversationHistoryCheckBox = nullptr; QCheckBox *m_saveConversationHistoryCheckBox = nullptr;
@@ -65,9 +72,11 @@ private:
QLabel *m_clearConversationStatusLabel = nullptr; QLabel *m_clearConversationStatusLabel = nullptr;
QComboBox *m_characterComboBox = nullptr; QComboBox *m_characterComboBox = nullptr;
AIConfigStore m_configStore; AIConfigStore m_configStore;
AIConfigStore m_acceptedConfigStore;
AppConfig m_appConfig; AppConfig m_appConfig;
QString m_currentProvider; QString m_currentProvider;
std::function<bool()> m_aiTestBlocked; std::function<bool()> m_aiTestBlocked;
std::function<void()> m_clearConversationHistory; std::function<void()> m_clearConversationHistory;
std::unique_ptr<LLMProvider> m_testProvider; std::unique_ptr<LLMProvider> m_testProvider;
bool m_hasAcceptedConfigStore = false;
}; };
+66
View File
@@ -0,0 +1,66 @@
#include "ResourcePaths.h"
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
namespace
{
QString cleanRelativePath(QString relativePath)
{
relativePath.replace(QLatin1Char('\\'), QLatin1Char('/'));
while (relativePath.startsWith(QLatin1Char('/')))
{
relativePath.remove(0, 1);
}
return QDir::cleanPath(relativePath);
}
QString appResourcesRootPath()
{
return QDir::cleanPath(QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("resources")));
}
QString sourceResourcesRootPath()
{
return QDir::cleanPath(QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/resources"));
}
}
QString ResourcePaths::resourcesRootPath()
{
const QString appResources = appResourcesRootPath();
if (QFileInfo::exists(appResources))
{
return appResources;
}
return sourceResourcesRootPath();
}
QString ResourcePaths::resourcePath(const QString &relativePath)
{
const QString cleanedRelativePath = cleanRelativePath(relativePath);
const QString appPath = QDir(appResourcesRootPath()).filePath(cleanedRelativePath);
if (QFileInfo::exists(appPath))
{
return QDir::cleanPath(appPath);
}
return QDir::cleanPath(QDir(sourceResourcesRootPath()).filePath(cleanedRelativePath));
}
QString ResourcePaths::charactersRootPath()
{
return resourcePath(QStringLiteral("characters"));
}
QString ResourcePaths::appIconPath()
{
return resourcePath(QStringLiteral("icons/app_icon.ico"));
}
QString ResourcePaths::appIconSourcePngPath()
{
return resourcePath(QStringLiteral("icons/app_icon_1024.png"));
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QString>
class ResourcePaths
{
public:
static QString resourcesRootPath();
static QString resourcePath(const QString &relativePath);
static QString charactersRootPath();
static QString appIconPath();
static QString appIconSourcePngPath();
};
+171
View File
@@ -0,0 +1,171 @@
param(
[string]$ProcessName = "QtDesktopPet",
[Alias("Pid")]
[int]$ProcessId = 0,
[ValidateRange(1, 86400)]
[int]$IntervalSeconds = 5,
[ValidateRange(1, 604800)]
[int]$DurationSeconds = 300,
[string]$OutputPath = "reports/perf"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-TargetProcess {
param(
[string]$Name,
[int]$Id
)
if ($Id -gt 0) {
return Get-Process -Id $Id -ErrorAction SilentlyContinue
}
$processes = @(Get-Process -Name $Name -ErrorAction SilentlyContinue)
if ($processes.Count -eq 0) {
return $null
}
if ($processes.Count -gt 1) {
Write-Warning "Multiple processes named '$Name' were found. Sampling PID $($processes[0].Id). Use -Pid to target a specific process."
}
return $processes[0]
}
function Read-ProcessSnapshot {
param(
[System.Diagnostics.Process]$Process
)
$path = ""
try {
$path = $Process.Path
}
catch {
$path = ""
}
$responding = ""
try {
$responding = $Process.Responding
}
catch {
$responding = ""
}
return [pscustomobject]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
Pid = $Process.Id
CpuSeconds = [double]($Process.CPU)
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
HandleCount = $Process.HandleCount
ThreadCount = $Process.Threads.Count
Responding = $responding
Path = $path
}
}
function New-OutputFilePath {
param(
[string]$Directory,
[string]$Name
)
if (-not (Test-Path -LiteralPath $Directory)) {
New-Item -ItemType Directory -Force -Path $Directory | Out-Null
}
$safeName = $Name -replace '[^a-zA-Z0-9._-]', '_'
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
return Join-Path $Directory "$timestamp-$safeName.csv"
}
$target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
if ($null -eq $target) {
if ($ProcessId -gt 0) {
Write-Error "Process with PID $ProcessId was not found."
}
else {
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
}
exit 1
}
$logicalProcessorCount = [Environment]::ProcessorCount
if ($logicalProcessorCount -lt 1) {
$logicalProcessorCount = 1
}
$outputFile = New-OutputFilePath -Directory $OutputPath -Name $target.ProcessName
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
Write-Host "Sampling PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
Write-Host "Output: $outputFile"
$previousSnapshot = Read-ProcessSnapshot -Process $target
$previousTime = Get-Date
$firstRow = [pscustomobject]@{
TimestampUtc = $previousSnapshot.TimestampUtc
Pid = $previousSnapshot.Pid
CpuPercent = 0
WorkingSetMB = $previousSnapshot.WorkingSetMB
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
HandleCount = $previousSnapshot.HandleCount
ThreadCount = $previousSnapshot.ThreadCount
Responding = $previousSnapshot.Responding
Path = $previousSnapshot.Path
Status = "running"
}
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
for ($index = 1; $index -le $sampleCount; $index++) {
Start-Sleep -Seconds $IntervalSeconds
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
if ($null -eq $currentProcess) {
[pscustomobject]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
Pid = $target.Id
CpuPercent = ""
WorkingSetMB = ""
PrivateMemoryMB = ""
HandleCount = ""
ThreadCount = ""
Responding = ""
Path = $previousSnapshot.Path
Status = "exited"
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
Write-Warning "Process $($target.Id) exited. Sampling stopped."
break
}
$currentSnapshot = Read-ProcessSnapshot -Process $currentProcess
$currentTime = Get-Date
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
if ($cpuPercent -lt 0) {
$cpuPercent = 0
}
[pscustomobject]@{
TimestampUtc = $currentSnapshot.TimestampUtc
Pid = $currentSnapshot.Pid
CpuPercent = [math]::Round($cpuPercent, 2)
WorkingSetMB = $currentSnapshot.WorkingSetMB
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
HandleCount = $currentSnapshot.HandleCount
ThreadCount = $currentSnapshot.ThreadCount
Responding = $currentSnapshot.Responding
Path = $currentSnapshot.Path
Status = "running"
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
$previousSnapshot = $currentSnapshot
$previousTime = $currentTime
}
Write-Host "Sampling finished."