diff --git a/.gitignore b/.gitignore index 874ba6b..06bd4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ cmake-build-*/ *.VC.db *.VC.VC.opendb *.autosave +*.broken.*.json # Build outputs *.exe @@ -21,4 +22,5 @@ cmake-build-*/ # Runtime/config files generated during development /config/ /logs/ +/reports/perf/ *.broken.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 32f5114..f497eb7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network) qt_add_executable(QtDesktopPet main.cpp + src/ai/AIDiagnostics.h + src/ai/AIDiagnostics.cpp src/ai/AIProviderFactory.h src/ai/AIProviderFactory.cpp src/ai/ConversationManager.h @@ -61,6 +63,8 @@ qt_add_executable(QtDesktopPet src/ui/PetWindow.cpp src/util/Logger.h src/util/Logger.cpp + src/util/ResourcePaths.h + src/util/ResourcePaths.cpp ) target_compile_definitions(QtDesktopPet @@ -75,6 +79,11 @@ target_link_libraries(QtDesktopPet ) 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_link_libraries(QtDesktopPet PRIVATE Crypt32) endif() diff --git a/README.md b/README.md index f71f14c..24cfa0f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型 - 文件日志和基础轮转 - 设置窗口 - 应用设置:缩放、性能模式、隐藏暂停、懒加载 +- 状态级动画预热和 LRU 缓存卸载 - AI Provider 分组配置 +- 设置页内 AI 连通性测试 - Windows DPAPI 加密保存 API Key - 非 Windows 环境经用户确认后明文保存 API Key - OpenAI Compatible 聊天请求 @@ -27,14 +29,15 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型 - 聊天输入框 - AI 回复气泡 - 对话历史面板 +- 内存历史上限和可选本地历史保存 - AI 请求取消和对话清空 - Google Gemini 原生聊天请求 尚未实现: -- 设置页内 AI 连通性测试 - 角色导入/切换界面 -- 对话历史持久化 +- 对话历史导出/管理界面 +- 长期性能压测记录 - 打包发布脚本 ## 技术栈 @@ -69,6 +72,18 @@ cmake --build build/mingw-debug 如果使用 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/ ``` -当前素材版本为 `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 ``` -配置损坏时会备份为: +配置损坏时会备份为带时间戳的文件: ```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 个旧日志文件 - 文件名为 `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 配置和聊天 当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口: @@ -148,15 +198,15 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log - API Key 不写入日志,不在错误提示中完整显示 - 对话历史面板记录用户消息和 AI 最终回复 -AI 测试入口已从角色右键菜单移除,后续会放入设置页。 +AI 测试入口已从角色右键菜单移除,并迁移到设置页。 ## 隐私说明 程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。 -日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header 或完整消息正文。 +日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header、完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要。 -当前对话历史保存在内存中,请求上下文只截取最近部分历史。后续仍需要补充内存历史上限和可选持久化策略。 +当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整。 ## 素材版权说明 diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/Qt_DesktopPet_开发文档.md index 550b2b8..4556ca5 100644 --- a/docs/Qt_DesktopPet_开发文档.md +++ b/docs/Qt_DesktopPet_开发文档.md @@ -488,8 +488,9 @@ AI 回复完成 → idle 配置文件损坏时,不要直接覆盖,先备份为: ```text -app_config.broken.json -ai_config.broken.json +app_config.broken.yyyyMMdd-HHmmss.json +ai_config.broken.yyyyMMdd-HHmmss.json +conversation_history.broken.yyyyMMdd-HHmmss.json ``` 然后再生成默认配置。 @@ -1682,8 +1683,8 @@ MIT License 开源 当前仍需补齐: ```text -1. 设置页内 AI 连通性测试 -2. 对话历史内存上限和可选持久化 -3. 角色包导入和角色切换 -4. 发布前素材授权确认与打包验证 +1. 角色包导入和角色切换 +2. 对话历史导出、搜索或更完整管理界面 +3. 发布前素材授权确认与打包验证 +4. 长期性能压测记录 ``` diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md index 36acd49..f7914ab 100644 --- a/docs/implementation_plan.md +++ b/docs/implementation_plan.md @@ -120,9 +120,10 @@ error :20 帧 ```text 1. character.json 中 base.width/base.height 当前为 512x512 -2. 当前实现会预加载当前角色包的全部状态帧,后续需要观察内存和低配设备表现 +2. 当前实现已接入状态级懒加载:enableLazyLoad=true 时先保存帧路径,状态首次播放时再加载对应帧 3. 如果项目公开发布或推送远程仓库,需要确认 shiroko 素材版权和再分发权限 4. 版权不明确前,不应把它作为正式开源发布素材承诺 +5. 当前已接入主线程分批预热和状态级 LRU 卸载,并避免低缓存上限下的重复预热/卸载循环;尚未做后台线程预热、单帧级缓存和长期压测记录 ``` --- @@ -285,7 +286,7 @@ error :20 帧 1. AnimationClip 2. FrameAnimator 3. 使用 QTimer 按 idle fps 播放帧 -4. 启动时预加载 idle 帧到内存 +4. 启动后播放 idle 时加载 idle 帧到内存 5. loop=true 时循环播放 6. 仍然只播放 idle 状态 ``` @@ -371,7 +372,7 @@ error :20 帧 5. 保存窗口位置、置顶状态、缩放、性能模式 6. Logger 7. 基础日志轮转 -8. 配置损坏时备份为 .broken.json +8. app config、AI config 和 conversation history 损坏时备份为带时间戳的 .broken 文件 ``` 暂不做: @@ -470,7 +471,7 @@ error :20 帧 4. Model 为空时有提示 5. 错误 API Key 不崩溃 6. 错误 URL 或超时不崩溃 -7. 日志不输出完整 API Key +7. 日志不输出完整 API Key、Authorization Header、完整消息正文和完整错误响应正文 8. 对话历史不会无限增长 ``` @@ -496,6 +497,16 @@ error :20 帧 7. 整理发布包 ``` +发布包排除: + +```text +tools/ +docs/ +reports/ +build/ +.git/ +``` + 暂不做: ```text @@ -550,7 +561,9 @@ error :20 帧 7. 阶段 5 稳定性与性能检查: 已做过一轮人工稳定性观察,包括静置、idle 动画、托盘隐藏/显示、重复状态切换和资源损坏兜底 - 当前尚未形成自动化性能测试或长期压测记录 + 已新增开发用性能采样脚本 tools/perf_sample.ps1 + 已新增稳定性检查记录模板 docs/performance_stability_check.md + 当前尚未形成长期压测记录 8. 阶段 6 AI 接入: 已新增 LLMProvider / OpenAICompatibleProvider / GoogleGeminiProvider / ConversationManager @@ -558,25 +571,30 @@ error :20 帧 已支持 Google Gemini generateContent / streamGenerateContent、x-goog-api-key、contents 多轮上下文和 systemInstruction 已支持 SSE 流式输出,气泡中流式显示,历史面板只记录最终对话 已限制同一时间只允许一个 AI 请求 - 已避免在日志中输出完整 API Key 和完整消息正文 + 已避免在日志中输出完整 API Key、Authorization Header、完整消息正文和完整错误响应正文 9. 阶段 7 UI 基础优化: 已新增 ChatBubble、ChatInputDialog、ChatHistoryPanel、SettingsDialog 已支持右键聊天、显示对话、取消 AI 请求、清空对话、设置 已删除临时 AI 测试入口和气泡测试入口 + 已将 AI 连通性测试迁移到设置页 已支持 OpenAI / Google / DeepSeek / Custom 配置分 Provider 保存 已移除废弃 Provider 配置入口,并在读取旧配置时清理废弃 Provider 配置 - 已支持应用设置页:缩放、性能模式、隐藏暂停、懒加载 - 已将 AppConfig 的 scale / performanceMode / pauseWhenHidden / enableLazyLoad 接入运行时 + 已支持内存历史上限和可选本地历史保存 + 已支持应用设置页:缩放、性能模式、隐藏暂停、懒加载、动画预热、动画缓存上限、隐藏时释放动画缓存 + 已将 AppConfig 的 scale / performanceMode / pauseWhenHidden / enableLazyLoad / enableAnimationPrewarm / animationCacheLimitMb / unloadAnimationsWhenHidden 接入运行时 + 懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态 Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存 + 运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/ ``` 当前实现与计划仍存在差异: ```text 1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局 -2. ConversationManager 请求上下文会截取最近 12 条历史,但内存中的 m_history 尚未做最大长度裁剪 -3. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 +2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面 +3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录 +4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 ``` --- @@ -596,9 +614,7 @@ error :20 帧 - 等待阶段拖动松开应回到 think - 收到首段回复后应进入 talk - 长文本流式输出期间应持续 talk -3. 给 ConversationManager 增加内存历史上限,避免长期对话无限增长 -4. 把 AI 测试能力迁移到后续设置页,不再放在角色右键菜单 -5. 用户手测应用设置: +3. 用户手测应用设置: - 缩放比例 - 标准 / 低功耗性能模式 - 隐藏到托盘时暂停动画 @@ -611,7 +627,8 @@ error :20 帧 1. 完善设置界面: - AI 配置和测试 - 角色包导入和角色切换 -2. 补一轮可重复的稳定性与性能测试记录 +2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录 +3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略 ``` --- @@ -622,6 +639,6 @@ error :20 帧 ```text 1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 -2. 设置页下一步先做 AI 测试入口,还是先做角色包配置 -3. 是否需要把对话历史持久化保存,还是第一版只保留内存会话 +2. 设置页下一步先完善角色包配置,还是先补发布打包配置 +3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理 ``` diff --git a/docs/performance_stability_check.md b/docs/performance_stability_check.md new file mode 100644 index 0000000..3c31669 --- /dev/null +++ b/docs/performance_stability_check.md @@ -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/ +``` diff --git a/main.cpp b/main.cpp index ade57c2..44696c0 100644 --- a/main.cpp +++ b/main.cpp @@ -1,11 +1,13 @@ #include #include +#include #include #include "src/config/ConfigManager.h" #include "src/tray/TrayController.h" #include "src/ui/PetWindow.h" #include "src/util/Logger.h" +#include "src/util/ResourcePaths.h" int main(int argc, char *argv[]) { @@ -13,6 +15,12 @@ int main(int argc, char *argv[]) QApplication::setApplicationName("QtDesktopPet"); QApplication::setOrganizationName("QtDesktopPet"); + const QIcon appIcon(ResourcePaths::appIconPath()); + if (!appIcon.isNull()) + { + QApplication::setWindowIcon(appIcon); + } + Logger::info(QStringLiteral("Application started.")); ConfigManager configManager; diff --git a/resources/icons/app_icon.ico b/resources/icons/app_icon.ico new file mode 100644 index 0000000..cb7c60f Binary files /dev/null and b/resources/icons/app_icon.ico differ diff --git a/resources/icons/app_icon.rc b/resources/icons/app_icon.rc new file mode 100644 index 0000000..386f6e2 --- /dev/null +++ b/resources/icons/app_icon.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON "app_icon.ico" diff --git a/resources/icons/app_icon_1024.png b/resources/icons/app_icon_1024.png new file mode 100644 index 0000000..56c14eb Binary files /dev/null and b/resources/icons/app_icon_1024.png differ diff --git a/src/ai/AIDiagnostics.cpp b/src/ai/AIDiagnostics.cpp new file mode 100644 index 0000000..181b2a8 --- /dev/null +++ b/src/ai/AIDiagnostics.cpp @@ -0,0 +1,225 @@ +#include "AIDiagnostics.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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(""); + } + + 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("") : 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("") : config.provider.trimmed()) + .arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("") : config.protocol.trimmed()) + .arg(config.model.trimmed().isEmpty() ? QStringLiteral("") : 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)); +} +} diff --git a/src/ai/AIDiagnostics.h b/src/ai/AIDiagnostics.h new file mode 100644 index 0000000..f595c5e --- /dev/null +++ b/src/ai/AIDiagnostics.h @@ -0,0 +1,16 @@ +#pragma once + +#include "../config/AIConfig.h" + +#include +#include +#include + +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); +} diff --git a/src/ai/ConversationStore.cpp b/src/ai/ConversationStore.cpp index 78f2a19..db2296d 100644 --- a/src/ai/ConversationStore.cpp +++ b/src/ai/ConversationStore.cpp @@ -2,6 +2,7 @@ #include "../util/Logger.h" +#include #include #include #include @@ -63,6 +64,40 @@ int overlappingMessageCount(const QVector &existingMessages, const 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) @@ -170,6 +205,8 @@ QVector ConversationStore::readMessages(QString *errorMessage) cons const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + file.close(); + backupBrokenConversationHistory(m_filePath); if (errorMessage != nullptr) { *errorMessage = QStringLiteral("Conversation history is not valid JSON."); diff --git a/src/ai/GoogleGeminiProvider.cpp b/src/ai/GoogleGeminiProvider.cpp index 2142ba8..aa7a0d2 100644 --- a/src/ai/GoogleGeminiProvider.cpp +++ b/src/ai/GoogleGeminiProvider.cpp @@ -1,5 +1,6 @@ #include "GoogleGeminiProvider.h" +#include "AIDiagnostics.h" #include "../util/Logger.h" #include @@ -15,135 +16,6 @@ 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("") : 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("") : config.provider.trimmed()) - .arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("") : config.protocol.trimmed()) - .arg(config.model.trimmed().isEmpty() ? QStringLiteral("") : 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) { model = model.trimmed(); @@ -331,7 +203,7 @@ void GoogleGeminiProvider::sendChatRequestInternal( const QJsonDocument document(buildPayload(request)); const QByteArray payload = document.toJson(QJsonDocument::Compact); 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(QString::number(request.messages.size())) .arg(QString::number(payload.size()))); @@ -403,7 +275,7 @@ void GoogleGeminiProvider::cancel() if (!reply.isNull()) { Logger::info(QStringLiteral("AI request canceled: %1") - .arg(diagnosticContext(m_config, reply->request().url()))); + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))); } clearReply(); @@ -512,13 +384,13 @@ ChatResponse GoogleGeminiProvider::parseResponse(QNetworkReply *reply, const QBy const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (reply->error() != QNetworkReply::NoError) { - const QString bodyError = errorMessageFromBody(body); - Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\"") - .arg(diagnosticContext(m_config, reply->request().url())) + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(static_cast(reply->error())) - .arg(oneLine(reply->errorString())) + .arg(AIDiagnostics::oneLine(reply->errorString())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::responseBodySummary(body))); if (!bodyError.isEmpty()) { @@ -532,22 +404,22 @@ ChatResponse GoogleGeminiProvider::parseResponse(QNetworkReply *reply, const QBy const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" body=\"%4\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" bodySummary=\"%4\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) - .arg(oneLine(parseError.errorString())) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::oneLine(parseError.errorString())) + .arg(AIDiagnostics::responseBodySummary(body))); return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus}; } const QJsonObject root = document.object(); if (root.contains(QStringLiteral("error"))) { - const QString bodyError = errorMessageFromBody(body); - Logger::warning(QStringLiteral("AI response returned error: %1 httpStatus=%2 body=\"%3\"") - .arg(diagnosticContext(m_config, reply->request().url())) + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + Logger::warning(QStringLiteral("AI response returned error: %1 httpStatus=%2 bodySummary=\"%3\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::responseBodySummary(body))); 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()) { const QString reason = geminiEmptyResponseReason(root); - Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 reason=\"%3\" body=\"%4\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 reason=\"%3\" bodySummary=\"%4\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) - .arg(oneLine(reason)) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::safeTextSummary(reason)) + .arg(AIDiagnostics::responseBodySummary(body))); return {false, {}, reason, httpStatus}; } 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(QString::number(content.size())) .arg(QString::number(body.size()))); @@ -629,8 +501,8 @@ bool GoogleGeminiProvider::handleStreamPayload(const QByteArray &payload) if (parseError.error != QJsonParseError::NoError || !document.isObject()) { Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3") - .arg(diagnosticContext(m_config, requestUrl(true))) - .arg(oneLine(parseError.errorString())) + .arg(AIDiagnostics::diagnosticContext(m_config, requestUrl(true))) + .arg(AIDiagnostics::oneLine(parseError.errorString())) .arg(QString::number(payload.size()))); return true; } @@ -638,7 +510,7 @@ bool GoogleGeminiProvider::handleStreamPayload(const QByteArray &payload) const QJsonObject root = document.object(); 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); return false; } @@ -666,13 +538,13 @@ ChatResponse GoogleGeminiProvider::finishStreamingResponse(QNetworkReply *reply, : QByteArray(); if (reply->error() != QNetworkReply::NoError) { - const QString bodyError = errorMessageFromBody(diagnosticBody); - Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\" streamedChars=%6") - .arg(diagnosticContext(m_config, reply->request().url())) + const QString bodyError = AIDiagnostics::errorMessageFromBody(diagnosticBody); + Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\" streamedChars=%6") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(static_cast(reply->error())) - .arg(oneLine(reply->errorString())) + .arg(AIDiagnostics::oneLine(reply->errorString())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(diagnosticBody))) + .arg(AIDiagnostics::responseBodySummary(diagnosticBody)) .arg(QString::number(m_streamedContent.size()))); if (!bodyError.isEmpty()) @@ -685,17 +557,17 @@ ChatResponse GoogleGeminiProvider::finishStreamingResponse(QNetworkReply *reply, if (m_streamedContent.isEmpty()) { - Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 body=\"%5\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 bodySummary=\"%5\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) .arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(QString::number(m_streamBuffer.size())) - .arg(oneLine(trimmedResponseBody(diagnosticBody)))); + .arg(AIDiagnostics::responseBodySummary(diagnosticBody))); return {false, {}, QStringLiteral("Gemini streaming response content is empty."), httpStatus}; } 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(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(QString::number(m_streamedContent.size()))); @@ -708,9 +580,9 @@ void GoogleGeminiProvider::finishWithError(const QString &message, int httpStatu QPointer reply = m_currentReply; const QUrl url = reply.isNull() ? requestUrl(m_streaming) : reply->request().url(); 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(oneLine(message))); + .arg(AIDiagnostics::safeTextSummary(message))); clearReply(); diff --git a/src/ai/OpenAICompatibleProvider.cpp b/src/ai/OpenAICompatibleProvider.cpp index 7c7ebf2..52992ec 100644 --- a/src/ai/OpenAICompatibleProvider.cpp +++ b/src/ai/OpenAICompatibleProvider.cpp @@ -1,5 +1,6 @@ #include "OpenAICompatibleProvider.h" +#include "AIDiagnostics.h" #include "../util/Logger.h" #include @@ -8,147 +9,10 @@ #include #include #include -#include #include -#include #include -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("") : 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("") : config.provider.trimmed()) - .arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("") : config.protocol.trimmed()) - .arg(config.model.trimmed().isEmpty() ? QStringLiteral("") : 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) : m_config(config) { @@ -239,7 +103,7 @@ void OpenAICompatibleProvider::sendChatRequestInternal( const QJsonDocument document(buildPayload(request, stream)); const QByteArray payload = document.toJson(QJsonDocument::Compact); 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(QString::number(request.messages.size())) .arg(QString::number(payload.size()))); @@ -311,7 +175,7 @@ void OpenAICompatibleProvider::cancel() if (!reply.isNull()) { Logger::info(QStringLiteral("AI request canceled: %1") - .arg(diagnosticContext(m_config, reply->request().url()))); + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))); } clearReply(); @@ -416,8 +280,8 @@ bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload) if (parseError.error != QJsonParseError::NoError || !document.isObject()) { Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3") - .arg(diagnosticContext(m_config, requestUrl())) - .arg(oneLine(parseError.errorString())) + .arg(AIDiagnostics::diagnosticContext(m_config, requestUrl())) + .arg(AIDiagnostics::oneLine(parseError.errorString())) .arg(QString::number(payload.size()))); return true; } @@ -425,7 +289,7 @@ bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload) const QJsonObject root = document.object(); 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); return false; } @@ -458,13 +322,13 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (reply->error() != QNetworkReply::NoError) { - const QString bodyError = errorMessageFromBody(body); - Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\"") - .arg(diagnosticContext(m_config, reply->request().url())) + const QString bodyError = AIDiagnostics::errorMessageFromBody(body); + Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(static_cast(reply->error())) - .arg(oneLine(reply->errorString())) + .arg(AIDiagnostics::oneLine(reply->errorString())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::responseBodySummary(body))); if (!bodyError.isEmpty()) { @@ -478,11 +342,11 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" body=\"%4\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" bodySummary=\"%4\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) - .arg(oneLine(parseError.errorString())) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::oneLine(parseError.errorString())) + .arg(AIDiagnostics::responseBodySummary(body))); 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(); if (choices.isEmpty() || !choices.first().isObject()) { - Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 body=\"%3\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 bodySummary=\"%3\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::responseBodySummary(body))); 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(); if (content.isEmpty()) { - Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 body=\"%3\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(body)))); + .arg(AIDiagnostics::responseBodySummary(body))); return {false, {}, QStringLiteral("AI response content is empty."), httpStatus}; } 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(QString::number(content.size())) .arg(QString::number(body.size()))); @@ -525,13 +389,13 @@ ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *re : QByteArray(); if (reply->error() != QNetworkReply::NoError) { - const QString bodyError = errorMessageFromBody(diagnosticBody); - Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\" streamedChars=%6") - .arg(diagnosticContext(m_config, reply->request().url())) + const QString bodyError = AIDiagnostics::errorMessageFromBody(diagnosticBody); + Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\" streamedChars=%6") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(static_cast(reply->error())) - .arg(oneLine(reply->errorString())) + .arg(AIDiagnostics::oneLine(reply->errorString())) .arg(httpStatus) - .arg(oneLine(trimmedResponseBody(diagnosticBody))) + .arg(AIDiagnostics::responseBodySummary(diagnosticBody)) .arg(QString::number(m_streamedContent.size()))); if (!bodyError.isEmpty()) @@ -544,17 +408,17 @@ ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *re if (m_streamedContent.isEmpty()) { - Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 body=\"%5\"") - .arg(diagnosticContext(m_config, reply->request().url())) + Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 bodySummary=\"%5\"") + .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) .arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(QString::number(m_streamBuffer.size())) - .arg(oneLine(trimmedResponseBody(diagnosticBody)))); + .arg(AIDiagnostics::responseBodySummary(diagnosticBody))); return {false, {}, QStringLiteral("AI streaming response content is empty."), httpStatus}; } 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(m_streamDone ? QStringLiteral("true") : QStringLiteral("false")) .arg(QString::number(m_streamedContent.size()))); @@ -567,9 +431,9 @@ void OpenAICompatibleProvider::finishWithError(const QString &message, int httpS QPointer reply = m_currentReply; const QUrl url = reply.isNull() ? requestUrl() : reply->request().url(); 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(oneLine(message))); + .arg(AIDiagnostics::safeTextSummary(message))); clearReply(); diff --git a/src/character/AnimationClip.cpp b/src/character/AnimationClip.cpp index 90092b7..69d3e42 100644 --- a/src/character/AnimationClip.cpp +++ b/src/character/AnimationClip.cpp @@ -25,6 +25,11 @@ bool AnimationClip::isValid() const return fps > 0 && (!m_frames.isEmpty() || !m_framePaths.isEmpty()); } +bool AnimationClip::isLoaded() const +{ + return !m_frames.isEmpty(); +} + bool AnimationClip::ensureLoaded() { if (m_frames.isEmpty()) @@ -35,6 +40,31 @@ bool AnimationClip::ensureLoaded() 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(frame.width()) + * static_cast(frame.height()) + * static_cast(frame.depth()) + / 8; + } + + return totalBytes; +} + +int AnimationClip::loadedFrameCount() const +{ + return m_frames.size(); +} + void AnimationClip::loadFrames() { if (!m_frames.isEmpty()) diff --git a/src/character/AnimationClip.h b/src/character/AnimationClip.h index 7946536..b0f2d4b 100644 --- a/src/character/AnimationClip.h +++ b/src/character/AnimationClip.h @@ -7,6 +7,7 @@ #include #include #include +#include class AnimationClip { @@ -14,7 +15,11 @@ public: static AnimationClip fromState(const CharacterState &state, const QSize &targetSize, bool loadFrames = true); bool isValid() const; + bool isLoaded() const; bool ensureLoaded(); + void unloadFrames(); + qint64 estimatedMemoryBytes() const; + int loadedFrameCount() const; const QPixmap &frameAt(int index) const; int frameCount() const; diff --git a/src/character/CharacterPackageRepository.cpp b/src/character/CharacterPackageRepository.cpp index b5f895a..5a66416 100644 --- a/src/character/CharacterPackageRepository.cpp +++ b/src/character/CharacterPackageRepository.cpp @@ -1,5 +1,7 @@ #include "CharacterPackageRepository.h" +#include "../util/ResourcePaths.h" + #include #include @@ -18,7 +20,7 @@ bool isValidCharacterId(const QString &characterId) QString CharacterPackageRepository::charactersRootPath() { - return QDir::cleanPath(QStringLiteral(PET_SOURCE_DIR) + QStringLiteral("/resources/characters")); + return ResourcePaths::charactersRootPath(); } QString CharacterPackageRepository::defaultCharacterId() diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index a8399c9..2963462 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -12,6 +12,9 @@ struct AppConfig QString performanceMode = QStringLiteral("standard"); bool pauseWhenHidden = true; bool enableLazyLoad = true; + bool enableAnimationPrewarm = true; + int animationCacheLimitMb = 180; + bool unloadAnimationsWhenHidden = true; int requestContextMessageLimit = 12; int memoryHistoryMessageLimit = 200; bool saveConversationHistory = false; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index 1409f9c..0b6ac06 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -2,6 +2,7 @@ #include "../util/Logger.h" +#include #include #include #include @@ -33,6 +34,9 @@ QJsonObject performanceObjectFromConfig(const AppConfig &config) performance.insert(QStringLiteral("mode"), config.performanceMode); performance.insert(QStringLiteral("pauseWhenHidden"), config.pauseWhenHidden); 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; } @@ -164,7 +168,7 @@ AppConfig ConfigManager::loadAppConfig() const if (parseError.error != QJsonParseError::NoError || !document.isObject()) { file.close(); - backupBrokenConfig(appConfigPath()); + backupBrokenConfig(appConfigPath(), QStringLiteral("app config")); Logger::warning(QStringLiteral("App config is broken; default config will be used.")); return config; } @@ -205,6 +209,21 @@ AppConfig ConfigManager::loadAppConfig() const 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(); if (chat.contains(QStringLiteral("requestContextMessageLimit"))) { @@ -250,7 +269,7 @@ AIConfigStore ConfigManager::loadAIConfigStore() const if (parseError.error != QJsonParseError::NoError || !document.isObject()) { file.close(); - backupBrokenConfig(aiConfigPath()); + backupBrokenConfig(aiConfigPath(), QStringLiteral("AI config")); Logger::warning(QStringLiteral("AI config is broken; default config will be used.")); return store; } @@ -387,7 +406,7 @@ QString ConfigManager::configDirectoryPath() const return QDir::currentPath(); } -void ConfigManager::backupBrokenConfig(const QString &filePath) const +void ConfigManager::backupBrokenConfig(const QString &filePath, const QString &configName) const { QFile file(filePath); if (!file.exists()) @@ -396,12 +415,27 @@ void ConfigManager::backupBrokenConfig(const QString &filePath) const } const QFileInfo fileInfo(filePath); - const QString backupPath = fileInfo.dir().filePath(fileInfo.completeBaseName() + QStringLiteral(".broken.json")); - if (QFile::exists(backupPath)) + 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)) { - QFile::remove(backupPath); + backupPath = fileInfo.dir().filePath( + fileInfo.completeBaseName() + + QStringLiteral(".broken.") + + timestamp + + QStringLiteral("-") + + QString::number(suffix) + + QStringLiteral(".json")); + ++suffix; } - file.rename(backupPath); - Logger::warning(QStringLiteral("Broken app config was backed up.")); + if (file.rename(backupPath)) + { + 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)); } diff --git a/src/config/ConfigManager.h b/src/config/ConfigManager.h index 2441cb6..46b9956 100644 --- a/src/config/ConfigManager.h +++ b/src/config/ConfigManager.h @@ -22,5 +22,5 @@ public: private: QString configDirectoryPath() const; - void backupBrokenConfig(const QString &filePath) const; + void backupBrokenConfig(const QString &filePath, const QString &configName) const; }; diff --git a/src/tray/TrayController.cpp b/src/tray/TrayController.cpp index 4c21e5f..151e589 100644 --- a/src/tray/TrayController.cpp +++ b/src/tray/TrayController.cpp @@ -2,16 +2,24 @@ #include "../character/CharacterPackageRepository.h" #include "../ui/PetWindow.h" +#include "../util/ResourcePaths.h" #include #include #include #include +#include namespace { QIcon loadTrayIcon() { + const QIcon appIcon(ResourcePaths::appIconPath()); + if (!appIcon.isNull()) + { + return appIcon; + } + const QPixmap pixmap(CharacterPackageRepository::defaultPreviewPath()); if (!pixmap.isNull()) { diff --git a/src/ui/ChatBubble.cpp b/src/ui/ChatBubble.cpp index 475fd5d..3dc13cb 100644 --- a/src/ui/ChatBubble.cpp +++ b/src/ui/ChatBubble.cpp @@ -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) { m_dismissOnExternalInteraction = false; diff --git a/src/ui/ChatBubble.h b/src/ui/ChatBubble.h index 30f9e1f..aec245e 100644 --- a/src/ui/ChatBubble.h +++ b/src/ui/ChatBubble.h @@ -10,6 +10,7 @@ class ChatBubble : public QWidget { public: explicit ChatBubble(QWidget *parent = nullptr); + ~ChatBubble() override; void showMessage( const QString &message, diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 8c82d8f..f612ce5 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -27,10 +28,12 @@ #include #include #include +#include #include #include #include +#include #include namespace @@ -42,6 +45,9 @@ constexpr int MinAnimationTargetSide = 32; constexpr int MaxAnimationTargetSide = 2048; constexpr int LowPowerFpsCap = 6; constexpr int ChatFinishedReturnDelayMs = 1500; +constexpr int StandardPrewarmIntervalMs = 800; +constexpr int LowPowerPrewarmIntervalMs = 1500; +constexpr qint64 BytesPerMegabyte = 1024 * 1024; int boundedAnimationTargetSide(double sideLength) { @@ -58,6 +64,11 @@ int evenBoundedHistoryLimit(int value, int minimum, int maximum) return boundedValue - (boundedValue % 2); } +QString megabytesText(qint64 bytes) +{ + return QString::number(static_cast(bytes) / static_cast(BytesPerMegabyte), 'f', 1); +} + AppConfig normalizedAppConfig(AppConfig config) { config.scale = qBound(0.5, config.scale, 2.0); @@ -66,6 +77,7 @@ AppConfig normalizedAppConfig(AppConfig config) { config.performanceMode = QStringLiteral("standard"); } + config.animationCacheLimitMb = qBound(64, config.animationCacheLimitMb, 1024); config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200); config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000); config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000); @@ -134,6 +146,11 @@ PetWindow::PetWindow(QWidget *parent) flushStreamingBubble(false); }); + m_animationPrewarmTimer.setSingleShot(true); + connect(&m_animationPrewarmTimer, &QTimer::timeout, this, [this]() { + processAnimationPrewarm(); + }); + QPointer window(this); m_chatInputDialog->setSubmitCallback([window](const QString &message) { return !window.isNull() && window->submitChatMessage(message); @@ -153,6 +170,10 @@ void PetWindow::applyAppConfig(const AppConfig &config) const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale) || m_appConfig.performanceMode != normalizedConfig.performanceMode || m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad; + const bool animationCachePolicyChanged = + m_appConfig.enableAnimationPrewarm != normalizedConfig.enableAnimationPrewarm + || m_appConfig.animationCacheLimitMb != normalizedConfig.animationCacheLimitMb + || m_appConfig.unloadAnimationsWhenHidden != normalizedConfig.unloadAnimationsWhenHidden; const bool loadPersistedHistory = !m_appConfig.saveConversationHistory && normalizedConfig.saveConversationHistory; @@ -182,6 +203,21 @@ void PetWindow::applyAppConfig(const AppConfig &config) 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 @@ -203,6 +239,7 @@ void PetWindow::pauseAnimation() m_returnToIdleAfterResume = m_behaviorReturnTimer.isActive(); m_idleBehaviorTimer.stop(); m_behaviorReturnTimer.stop(); + stopAnimationPrewarm(); m_frameAnimator.pause(); } @@ -220,6 +257,7 @@ void PetWindow::resumeAnimation() } m_returnToIdleAfterResume = false; + scheduleAnimationPrewarm(); } void PetWindow::showBubbleMessage(const QString &message) @@ -381,6 +419,7 @@ bool PetWindow::submitChatMessage(const QString &message) return false; } + stopAnimationPrewarm(); playState(QStringLiteral("think"), false); m_streamingAssistantText.clear(); m_streamBubbleUpdateTimer.stop(); @@ -617,6 +656,7 @@ void PetWindow::finishStreamingChat() { m_streamingChatActive = false; m_streamingTalkStarted = false; + scheduleAnimationPrewarm(); } void PetWindow::cancelStreamingChat() @@ -625,6 +665,7 @@ void PetWindow::cancelStreamingChat() m_streamingAssistantText.clear(); m_streamingChatActive = false; m_streamingTalkStarted = false; + scheduleAnimationPrewarm(); } void PetWindow::resetBubbleAutoHideTimer() @@ -643,6 +684,7 @@ QPoint PetWindow::chatInputAnchorPosition() const void PetWindow::hideEvent(QHideEvent *event) { m_streamBubbleUpdateTimer.stop(); + stopAnimationPrewarm(); if (m_chatBubble) { m_chatBubble->hideBubble(); @@ -656,9 +698,20 @@ void PetWindow::hideEvent(QHideEvent *event) m_chatHistoryPanel->hide(); } + if (isAnimationCacheManagementEnabled() && m_appConfig.unloadAnimationsWhenHidden) + { + unloadNonProtectedAnimationCache(QStringLiteral("window hidden")); + } + QWidget::hideEvent(event); } +void PetWindow::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + scheduleAnimationPrewarm(); +} + void PetWindow::mouseDoubleClickEvent(QMouseEvent *event) { resetBubbleAutoHideTimer(); @@ -702,6 +755,7 @@ void PetWindow::mousePressEvent(QMouseEvent *event) { m_dragging = true; m_dragOffset = event->globalPosition().toPoint() - frameGeometry().topLeft(); + stopAnimationPrewarm(); playResolvedState(m_stateMachine.beginDrag(), false); event->accept(); return; @@ -718,6 +772,7 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event) { m_dragging = false; playResolvedState(m_stateMachine.endDrag(), false); + scheduleAnimationPrewarm(); event->accept(); return; } @@ -747,6 +802,11 @@ void PetWindow::loadInitialImage() void PetWindow::buildAnimationClips() { + stopAnimationPrewarm(); + m_animationPrewarmQueue.clear(); + m_animationPrewarmAttemptedStates.clear(); + m_clipLastAccessSerial.clear(); + m_clipAccessSerial = 0; m_clips.clear(); QSet availableStates; @@ -849,6 +909,7 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) if (m_frameAnimator.currentStateName() == stateName && m_frameAnimator.isPlaying()) { + noteAnimationClipAccess(stateName); return; } @@ -859,11 +920,22 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) } AnimationClip *clip = &clipIterator.value(); + const bool wasLoaded = clip->isLoaded(); if (!clip->ensureLoaded()) { + Logger::warning(QStringLiteral("Animation state failed to load: state=%1").arg(stateName)); return; } + noteAnimationClipAccess(stateName); + if (!wasLoaded) + { + Logger::info(QStringLiteral("Animation state loaded: state=%1 frames=%2 cacheMb=%3") + .arg(stateName) + .arg(QString::number(clip->loadedFrameCount())) + .arg(megabytesText(clip->estimatedMemoryBytes()))); + } + m_idleBehaviorTimer.stop(); m_behaviorReturnTimer.stop(); m_centerNextFrame = centerWindow; @@ -877,6 +949,9 @@ void PetWindow::playResolvedState(const QString &stateName, bool centerWindow) { m_behaviorReturnTimer.start(4000); } + + trimAnimationCache(QStringLiteral("state played")); + scheduleAnimationPrewarm(); } QSize PetWindow::animationTargetSize() const @@ -903,6 +978,311 @@ bool PetWindow::isLowPowerMode() const return m_appConfig.performanceMode == QStringLiteral("low-power"); } +bool PetWindow::isAnimationCacheManagementEnabled() const +{ + return m_appConfig.enableLazyLoad; +} + +void PetWindow::rebuildAnimationPrewarmQueue() +{ + m_animationPrewarmQueue.clear(); + if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm) + { + return; + } + + const auto appendStateIfNeeded = [this](const QString &stateName) { + if (stateName == QStringLiteral("idle") + || m_animationPrewarmQueue.contains(stateName) + || m_animationPrewarmAttemptedStates.contains(stateName)) + { + return; + } + + const auto iterator = m_clips.constFind(stateName); + if (iterator != m_clips.constEnd() && !iterator.value().isLoaded()) + { + m_animationPrewarmQueue.append(stateName); + } + }; + + appendStateIfNeeded(QStringLiteral("drag")); + appendStateIfNeeded(QStringLiteral("think")); + appendStateIfNeeded(QStringLiteral("talk")); + + QStringList stateNames = m_clips.keys(); + stateNames.sort(); + for (const QString &stateName : stateNames) + { + appendStateIfNeeded(stateName); + } +} + +void PetWindow::scheduleAnimationPrewarm() +{ + if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm) + { + stopAnimationPrewarm(); + return; + } + + if (!isVisible() || m_dragging || m_streamingChatActive || hasActiveAIRequest()) + { + stopAnimationPrewarm(); + return; + } + + if (m_animationPrewarmQueue.isEmpty()) + { + rebuildAnimationPrewarmQueue(); + } + + if (m_animationPrewarmQueue.isEmpty() || m_animationPrewarmTimer.isActive()) + { + return; + } + + m_animationPrewarmTimer.start(isLowPowerMode() ? LowPowerPrewarmIntervalMs : StandardPrewarmIntervalMs); +} + +void PetWindow::stopAnimationPrewarm() +{ + m_animationPrewarmTimer.stop(); +} + +void PetWindow::processAnimationPrewarm() +{ + m_animationPrewarmTimer.stop(); + if (!isAnimationCacheManagementEnabled() || !m_appConfig.enableAnimationPrewarm) + { + return; + } + + if (!isVisible() || m_dragging || m_streamingChatActive || hasActiveAIRequest()) + { + return; + } + + while (!m_animationPrewarmQueue.isEmpty()) + { + const QString stateName = m_animationPrewarmQueue.takeFirst(); + m_animationPrewarmAttemptedStates.insert(stateName); + auto clipIterator = m_clips.find(stateName); + if (clipIterator == m_clips.end() || clipIterator.value().isLoaded()) + { + continue; + } + + AnimationClip &clip = clipIterator.value(); + if (!clip.ensureLoaded()) + { + Logger::warning(QStringLiteral("Animation state prewarm failed: state=%1").arg(stateName)); + continue; + } + + noteAnimationClipAccess(stateName); + Logger::info(QStringLiteral("Animation state prewarmed: state=%1 frames=%2 cacheMb=%3") + .arg(stateName) + .arg(QString::number(clip.loadedFrameCount())) + .arg(megabytesText(clip.estimatedMemoryBytes()))); + trimAnimationCache(QStringLiteral("prewarm")); + break; + } + + scheduleAnimationPrewarm(); +} + +void PetWindow::noteAnimationClipAccess(const QString &stateName) +{ + if (!m_clips.contains(stateName)) + { + return; + } + + ++m_clipAccessSerial; + m_clipLastAccessSerial.insert(stateName, m_clipAccessSerial); +} + +qint64 PetWindow::animationCacheLimitBytes() const +{ + return static_cast(m_appConfig.animationCacheLimitMb) * BytesPerMegabyte; +} + +qint64 PetWindow::loadedAnimationCacheBytes() const +{ + qint64 totalBytes = 0; + for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator) + { + totalBytes += iterator.value().estimatedMemoryBytes(); + } + + return totalBytes; +} + +int PetWindow::loadedAnimationClipCount() const +{ + int count = 0; + for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator) + { + if (iterator.value().isLoaded()) + { + ++count; + } + } + + return count; +} + +QSet PetWindow::protectedAnimationStates() const +{ + QSet states; + states.insert(QStringLiteral("idle")); + + const QString animatorState = m_frameAnimator.currentStateName(); + if (!animatorState.isEmpty()) + { + states.insert(animatorState); + } + + const QString stateMachineState = m_stateMachine.currentState(); + if (!stateMachineState.isEmpty()) + { + states.insert(stateMachineState); + } + + if (m_streamingChatActive || hasActiveAIRequest()) + { + states.insert(QStringLiteral("think")); + states.insert(QStringLiteral("talk")); + } + + if (m_dragging) + { + states.insert(QStringLiteral("drag")); + } + + return states; +} + +void PetWindow::trimAnimationCache(const QString &reason) +{ + if (!isAnimationCacheManagementEnabled()) + { + return; + } + + qint64 totalBytes = loadedAnimationCacheBytes(); + const qint64 limitBytes = animationCacheLimitBytes(); + if (totalBytes <= limitBytes) + { + return; + } + + struct UnloadCandidate + { + QString stateName; + qint64 lastAccessSerial = 0; + }; + + const QSet protectedStates = protectedAnimationStates(); + QList candidates; + QStringList protectedLoadedStates; + for (auto iterator = m_clips.constBegin(); iterator != m_clips.constEnd(); ++iterator) + { + if (!iterator.value().isLoaded()) + { + continue; + } + + if (protectedStates.contains(iterator.key())) + { + protectedLoadedStates.append(iterator.key()); + continue; + } + + candidates.append({iterator.key(), m_clipLastAccessSerial.value(iterator.key(), 0)}); + } + + std::sort(candidates.begin(), candidates.end(), [](const UnloadCandidate &left, const UnloadCandidate &right) { + if (left.lastAccessSerial == right.lastAccessSerial) + { + return left.stateName < right.stateName; + } + + return left.lastAccessSerial < right.lastAccessSerial; + }); + + for (const UnloadCandidate &candidate : candidates) + { + if (totalBytes <= limitBytes) + { + break; + } + + auto clipIterator = m_clips.find(candidate.stateName); + if (clipIterator == m_clips.end() || !clipIterator.value().isLoaded()) + { + continue; + } + + const qint64 freedBytes = clipIterator.value().estimatedMemoryBytes(); + clipIterator.value().unloadFrames(); + m_clipLastAccessSerial.remove(candidate.stateName); + totalBytes -= freedBytes; + Logger::info(QStringLiteral("Animation state unloaded: state=%1 reason=%2 freedMb=%3 cacheMb=%4 loadedStates=%5") + .arg(candidate.stateName) + .arg(reason) + .arg(megabytesText(freedBytes)) + .arg(megabytesText(totalBytes)) + .arg(QString::number(loadedAnimationClipCount()))); + } + + if (totalBytes > limitBytes) + { + protectedLoadedStates.sort(); + Logger::warning(QStringLiteral("Animation cache remains over limit: reason=%1 cacheMb=%2 limitMb=%3 protectedStates=%4") + .arg(reason) + .arg(megabytesText(totalBytes)) + .arg(QString::number(m_appConfig.animationCacheLimitMb)) + .arg(protectedLoadedStates.join(QStringLiteral(",")))); + } +} + +void PetWindow::unloadNonProtectedAnimationCache(const QString &reason) +{ + if (!isAnimationCacheManagementEnabled()) + { + return; + } + + const QSet protectedStates = protectedAnimationStates(); + for (auto iterator = m_clips.begin(); iterator != m_clips.end(); ++iterator) + { + if (!iterator.value().isLoaded()) + { + continue; + } + + if (protectedStates.contains(iterator.key())) + { + Logger::info(QStringLiteral("Animation state kept loaded: state=%1 reason=%2") + .arg(iterator.key()) + .arg(reason)); + continue; + } + + const qint64 freedBytes = iterator.value().estimatedMemoryBytes(); + iterator.value().unloadFrames(); + m_clipLastAccessSerial.remove(iterator.key()); + Logger::info(QStringLiteral("Animation state unloaded: state=%1 reason=%2 freedMb=%3 cacheMb=%4 loadedStates=%5") + .arg(iterator.key()) + .arg(reason) + .arg(megabytesText(freedBytes)) + .arg(megabytesText(loadedAnimationCacheBytes())) + .arg(QString::number(loadedAnimationClipCount()))); + } +} + void PetWindow::scheduleIdleBehavior() { if (!m_clips.contains(QStringLiteral("idle"))) diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 2db6a6f..e581459 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -8,8 +8,11 @@ #include #include +#include +#include #include #include +#include #include @@ -17,6 +20,7 @@ class QMenu; class QHideEvent; class QMoveEvent; class QPixmap; +class QShowEvent; class ChatBubble; class ChatHistoryPanel; class ChatInputDialog; @@ -41,6 +45,7 @@ public: protected: void contextMenuEvent(QContextMenuEvent *event) override; void hideEvent(QHideEvent *event) override; + void showEvent(QShowEvent *event) override; void mouseDoubleClickEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -75,6 +80,18 @@ private: QSize animationTargetSize() const; int effectiveAnimationFps(int fps) 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 protectedAnimationStates() const; + void trimAnimationCache(const QString &reason); + void unloadNonProtectedAnimationCache(const QString &reason); void scheduleIdleBehavior(); void playIdleBehavior(); void returnToIdleFromBehavior(); @@ -92,13 +109,18 @@ private: QTimer m_idleBehaviorTimer; QTimer m_behaviorReturnTimer; QTimer m_streamBubbleUpdateTimer; + QTimer m_animationPrewarmTimer; AppConfig m_appConfig; CharacterPackage m_characterPackage; QMap m_clips; + QMap m_clipLastAccessSerial; + QSet m_animationPrewarmAttemptedStates; FrameAnimator m_frameAnimator; PetStateMachine m_stateMachine; QPoint m_dragOffset; QString m_streamingAssistantText; + QStringList m_animationPrewarmQueue; + qint64 m_clipAccessSerial = 0; bool m_dragging; bool m_alwaysOnTop; bool m_centerNextFrame; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 8416dde..b629b3b 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -81,6 +81,9 @@ SettingsDialog::SettingsDialog( , m_performanceModeComboBox(new QComboBox(this)) , m_pauseWhenHiddenCheckBox(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_memoryHistoryMessageLimitSpinBox(new QSpinBox(this)) , m_saveConversationHistoryCheckBox(new QCheckBox(QStringLiteral("保存聊天记录到本地"), this)) @@ -135,6 +138,12 @@ SettingsDialog::SettingsDialog( m_pauseWhenHiddenCheckBox->setChecked(m_appConfig.pauseWhenHidden); 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->setValue(qBound(0, m_appConfig.requestContextMessageLimit, 200)); @@ -217,6 +226,9 @@ SettingsDialog::SettingsDialog( appFormLayout->addRow(QStringLiteral("性能模式"), m_performanceModeComboBox); appFormLayout->addRow(QString(), m_pauseWhenHiddenCheckBox); appFormLayout->addRow(QString(), m_enableLazyLoadCheckBox); + appFormLayout->addRow(QString(), m_enableAnimationPrewarmCheckBox); + appFormLayout->addRow(QStringLiteral("动画缓存上限"), m_animationCacheLimitSpinBox); + appFormLayout->addRow(QString(), m_unloadAnimationsWhenHiddenCheckBox); auto *appPageLayout = new QVBoxLayout(); appPageLayout->setContentsMargins(24, 24, 24, 24); @@ -332,7 +344,7 @@ SettingsDialog::SettingsDialog( contentLayout->addWidget(pageStack, 1); 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); auto *layout = new QVBoxLayout(this); @@ -381,6 +393,13 @@ SettingsDialog::SettingsDialog( connect(m_testConnectionButton, &QPushButton::clicked, this, [this]() { 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_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() { this->clearConversationHistory(); @@ -397,13 +416,19 @@ SettingsDialog::~SettingsDialog() AIConfigStore SettingsDialog::aiConfigStore() const { - AIConfigStore store = m_configStore; - store.providers.remove(QStringLiteral("claude")); - store.providers.remove(QStringLiteral("calude")); - const QString provider = normalizedProviderName(m_providerComboBox->currentData().toString()); - store.activeProvider = provider; - store.providers.insert(provider, configFromForm(provider)); - return store; + if (m_hasAcceptedConfigStore) + { + return m_acceptedConfigStore; + } + + AIConfigStore store; + QString errorMessage; + if (buildAIConfigStore(&store, &errorMessage)) + { + return store; + } + + return m_configStore; } AppConfig SettingsDialog::appConfig() const @@ -417,6 +442,9 @@ AppConfig SettingsDialog::appConfig() const } config.pauseWhenHidden = m_pauseWhenHiddenCheckBox->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.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value(); config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked(); @@ -424,6 +452,61 @@ AppConfig SettingsDialog::appConfig() const 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() { if (m_currentProvider.isEmpty()) @@ -431,7 +514,15 @@ void SettingsDialog::cacheCurrentProvider() 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) @@ -469,10 +560,13 @@ void SettingsDialog::switchProvider(const QString &provider) m_currentProvider = normalizedProvider; } -AIConfig SettingsDialog::configFromForm(const QString &provider) const +AIConfig SettingsDialog::configFromForm(const QString &provider, QString *errorMessage) const { const QString normalizedProvider = normalizedProviderName(provider); 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.protocol = defaultAIConfigForProvider(normalizedProvider).protocol; config.baseUrl = m_baseUrlEdit->text().trimmed(); @@ -501,7 +595,13 @@ AIConfig SettingsDialog::configFromForm(const QString &provider) const } 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) diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index f166755..32f0361 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -31,11 +31,15 @@ public: AIConfigStore aiConfigStore() const; AppConfig appConfig() const; +protected: + void accept() override; + private: void cacheCurrentProvider(); void loadProviderConfig(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; QString decryptedApiKey(const AIConfig &config) const; void testConnection(); @@ -57,6 +61,9 @@ private: QComboBox *m_performanceModeComboBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_enableLazyLoadCheckBox = nullptr; + QCheckBox *m_enableAnimationPrewarmCheckBox = nullptr; + QSpinBox *m_animationCacheLimitSpinBox = nullptr; + QCheckBox *m_unloadAnimationsWhenHiddenCheckBox = nullptr; QSpinBox *m_requestContextMessageLimitSpinBox = nullptr; QSpinBox *m_memoryHistoryMessageLimitSpinBox = nullptr; QCheckBox *m_saveConversationHistoryCheckBox = nullptr; @@ -65,9 +72,11 @@ private: QLabel *m_clearConversationStatusLabel = nullptr; QComboBox *m_characterComboBox = nullptr; AIConfigStore m_configStore; + AIConfigStore m_acceptedConfigStore; AppConfig m_appConfig; QString m_currentProvider; std::function m_aiTestBlocked; std::function m_clearConversationHistory; std::unique_ptr m_testProvider; + bool m_hasAcceptedConfigStore = false; }; diff --git a/src/util/ResourcePaths.cpp b/src/util/ResourcePaths.cpp new file mode 100644 index 0000000..614e316 --- /dev/null +++ b/src/util/ResourcePaths.cpp @@ -0,0 +1,66 @@ +#include "ResourcePaths.h" + +#include +#include +#include + +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")); +} diff --git a/src/util/ResourcePaths.h b/src/util/ResourcePaths.h new file mode 100644 index 0000000..01e0a63 --- /dev/null +++ b/src/util/ResourcePaths.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class ResourcePaths +{ +public: + static QString resourcesRootPath(); + static QString resourcePath(const QString &relativePath); + static QString charactersRootPath(); + static QString appIconPath(); + static QString appIconSourcePngPath(); +}; diff --git a/tools/perf_sample.ps1 b/tools/perf_sample.ps1 new file mode 100644 index 0000000..e90381e --- /dev/null +++ b/tools/perf_sample.ps1 @@ -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."