收敛稳定性风险

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.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
+9
View File
@@ -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()
+58 -8
View File
@@ -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完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要
当前对话历史保存在内存中,请求上下文截取最近部分历史。后续仍需要补充内存历史上限和可选持久化策略
当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整
## 素材版权说明
+7 -6
View File
@@ -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. 长期性能压测记录
```
+33 -16
View File
@@ -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 分组管理
```
+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 <QCoreApplication>
#include <QIcon>
#include <QObject>
#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;
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 <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
@@ -63,6 +64,40 @@ int overlappingMessageCount(const QVector<ChatMessage> &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<ChatMessage> 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.");
+35 -163
View File
@@ -1,5 +1,6 @@
#include "GoogleGeminiProvider.h"
#include "AIDiagnostics.h"
#include "../util/Logger.h"
#include <QJsonArray>
@@ -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("<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)
{
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<int>(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<int>(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<QNetworkReply> 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();
+33 -169
View File
@@ -1,5 +1,6 @@
#include "OpenAICompatibleProvider.h"
#include "AIDiagnostics.h"
#include "../util/Logger.h"
#include <QJsonArray>
@@ -8,147 +9,10 @@
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QUrl>
#include <QUrlQuery>
#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)
: 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<int>(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<int>(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<QNetworkReply> 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();
+30
View File
@@ -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<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()
{
if (!m_frames.isEmpty())
+5
View File
@@ -7,6 +7,7 @@
#include <QString>
#include <QStringList>
#include <QVector>
#include <QtGlobal>
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;
+3 -1
View File
@@ -1,5 +1,7 @@
#include "CharacterPackageRepository.h"
#include "../util/ResourcePaths.h"
#include <QDir>
#include <QFileInfo>
@@ -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()
+3
View File
@@ -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;
+42 -8
View File
@@ -2,6 +2,7 @@
#include "../util/Logger.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
@@ -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));
}
+1 -1
View File
@@ -22,5 +22,5 @@ public:
private:
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 "../ui/PetWindow.h"
#include "../util/ResourcePaths.h"
#include <QAction>
#include <QApplication>
#include <QIcon>
#include <QPixmap>
#include <QString>
namespace
{
QIcon loadTrayIcon()
{
const QIcon appIcon(ResourcePaths::appIconPath());
if (!appIcon.isNull())
{
return appIcon;
}
const QPixmap pixmap(CharacterPackageRepository::defaultPreviewPath());
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)
{
m_dismissOnExternalInteraction = false;
+1
View File
@@ -10,6 +10,7 @@ class ChatBubble : public QWidget
{
public:
explicit ChatBubble(QWidget *parent = nullptr);
~ChatBubble() override;
void showMessage(
const QString &message,
+380
View File
@@ -19,6 +19,7 @@
#include <QDialog>
#include <QGuiApplication>
#include <QHideEvent>
#include <QList>
#include <QMenu>
#include <QMouseEvent>
#include <QPixmap>
@@ -27,10 +28,12 @@
#include <QRandomGenerator>
#include <QScreen>
#include <QSet>
#include <QShowEvent>
#include <QStringList>
#include <QVBoxLayout>
#include <QtGlobal>
#include <algorithm>
#include <memory>
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<double>(bytes) / static_cast<double>(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<PetWindow> 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<QString> 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<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()
{
if (!m_clips.contains(QStringLiteral("idle")))
+22
View File
@@ -8,8 +8,11 @@
#include <QMap>
#include <QPoint>
#include <QSet>
#include <QStringList>
#include <QTimer>
#include <QWidget>
#include <QtGlobal>
#include <memory>
@@ -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<QString> 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<QString, AnimationClip> m_clips;
QMap<QString, qint64> m_clipLastAccessSerial;
QSet<QString> 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;
+110 -10
View File
@@ -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));
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)
+10 -1
View File
@@ -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<bool()> m_aiTestBlocked;
std::function<void()> m_clearConversationHistory;
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."