8 Commits

113 changed files with 17751 additions and 548 deletions
+2
View File
@@ -25,4 +25,6 @@ cmake-build-*/
/config/ /config/
/logs/ /logs/
/reports/perf/ /reports/perf/
/reports/qa/
/internal/qa/out/
*.broken.json *.broken.json
+46 -4
View File
@@ -1,16 +1,14 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(QtDesktopPet VERSION 0.1.0 LANGUAGES CXX) project(QtDesktopPet VERSION 0.2.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_AUTOMOC OFF) set(CMAKE_AUTOMOC OFF)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Network) find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
qt_add_executable(QtDesktopPet qt_add_executable(QtDesktopPet
main.cpp main.cpp
@@ -28,6 +26,11 @@ qt_add_executable(QtDesktopPet
src/ai/GoogleGeminiProvider.cpp src/ai/GoogleGeminiProvider.cpp
src/ai/OpenAICompatibleProvider.h src/ai/OpenAICompatibleProvider.h
src/ai/OpenAICompatibleProvider.cpp src/ai/OpenAICompatibleProvider.cpp
src/assistant/CommandDispatcher.h
src/assistant/CommandDispatcher.cpp
src/assistant/IntentRouter.h
src/assistant/IntentRouter.cpp
src/assistant/UserIntent.h
src/character/AnimationClip.h src/character/AnimationClip.h
src/character/AnimationClip.cpp src/character/AnimationClip.cpp
src/character/CharacterPackage.h src/character/CharacterPackage.h
@@ -45,8 +48,26 @@ qt_add_executable(QtDesktopPet
src/config/ConfigManager.cpp src/config/ConfigManager.cpp
src/config/SecretStore.h src/config/SecretStore.h
src/config/SecretStore.cpp src/config/SecretStore.cpp
src/notification/NotificationDispatcher.h
src/notification/NotificationDispatcher.cpp
src/reminder/ReminderCommandHandler.h
src/reminder/ReminderCommandHandler.cpp
src/reminder/ReminderManager.h
src/reminder/ReminderManager.cpp
src/reminder/ReminderParser.h
src/reminder/ReminderParser.cpp
src/reminder/ReminderSoundPlayer.h
src/reminder/ReminderSoundPlayer.cpp
src/reminder/ReminderSoundRepository.h
src/reminder/ReminderSoundRepository.cpp
src/reminder/ReminderStore.h
src/reminder/ReminderStore.cpp
src/reminder/ReminderTypes.h
src/reminder/ReminderTypes.cpp
src/state/PetStateMachine.h src/state/PetStateMachine.h
src/state/PetStateMachine.cpp src/state/PetStateMachine.cpp
src/system/StartupManager.h
src/system/StartupManager.cpp
src/tray/TrayController.h src/tray/TrayController.h
src/tray/TrayController.cpp src/tray/TrayController.cpp
src/ui/ChatBubble.h src/ui/ChatBubble.h
@@ -65,6 +86,26 @@ qt_add_executable(QtDesktopPet
src/util/Logger.cpp src/util/Logger.cpp
src/util/ResourcePaths.h src/util/ResourcePaths.h
src/util/ResourcePaths.cpp src/util/ResourcePaths.cpp
src/weather/WeatherConfig.h
src/weather/WeatherManager.h
src/weather/WeatherManager.cpp
src/weather/WeatherLocationResolver.h
src/weather/WeatherLocationResolver.cpp
src/weather/WeatherParser.h
src/weather/WeatherParser.cpp
src/weather/WeatherStore.h
src/weather/WeatherStore.cpp
src/weather/WeatherSummaryFormatter.h
src/weather/WeatherSummaryFormatter.cpp
src/weather/WeatherTypes.h
src/web/WebCapabilityDetector.h
src/web/WebCapabilityDetector.cpp
src/web/WebChatManager.h
src/web/WebChatManager.cpp
src/web/WebChatTypes.h
src/web/WebConfig.h
src/web/WebStore.h
src/web/WebStore.cpp
) )
target_compile_definitions(QtDesktopPet target_compile_definitions(QtDesktopPet
@@ -74,6 +115,7 @@ target_compile_definitions(QtDesktopPet
target_link_libraries(QtDesktopPet target_link_libraries(QtDesktopPet
PRIVATE PRIVATE
Qt6::Multimedia
Qt6::Network Qt6::Network
Qt6::Widgets Qt6::Widgets
) )
+543
View File
@@ -0,0 +1,543 @@
# QtDesktopPet
QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目,当前已具备多状态 PNG 帧动画、托盘控制、角色包导入与切换、用户自定义大模型对话、设置面板和 Windows 发布打包能力。项目现阶段重点是完善稳定性、性能回归、角色管理和发布体验。
## 当前状态
已实现:
- 透明无边框桌宠窗口
- 鼠标拖动
- 右键菜单退出和状态测试
- 置顶切换
- `resources/characters/shiroko` 默认角色包读取
- PNG 序列帧动画播放
- `idle` / `talk` / `think` / `sleep` / `happy` / `drag` / `error` 状态
- 托盘显示、隐藏、退出
- 单实例限制,重复启动会唤醒已有实例
- 隐藏时暂停动画,显示时恢复动画
- 保存窗口位置、置顶、缩放和性能设置
- 文件日志和基础轮转
- 设置窗口按当前屏幕居中弹出
- 应用设置:缩放、性能模式、隐藏暂停、懒加载
- 状态级动画预热和 LRU 缓存卸载
- AI Provider 分组配置
- 设置页内 AI 连通性测试
- Windows DPAPI 加密保存 API Key
- 非 Windows 环境经用户确认后明文保存 API Key
- OpenAI Compatible 聊天请求
- SSE 流式输出
- 聊天输入框
- AI 回复气泡
- 对话历史面板
- 内存历史上限、可选本地历史保存、搜索和 Markdown/JSON 导出
- AI 请求取消和对话清空
- Google Gemini 原生聊天请求
- 角色文件夹导入和角色切换
- 删除用户导入角色
- 角色导出和打开用户角色目录
- 本地一次性和重复提醒:聊天创建、查询、取消,重启后 pending 提醒不丢
- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知
- 提醒音效切换、试听、用户 wav 导入和删除
- 天气查询 v1:Open-Meteo 天气源、城市解析、默认城市、公网 IP 定位兜底和模板回复
- 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding
- Windows 发布打包脚本和 Inno Setup 安装器脚本
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
尚未实现:
- 长期性能压测记录
- 发布包实机安装/卸载验证
- 搜索网页全文抓取、长期缓存和浏览器自动打开网页
当前非目标:
- 本地文件访问、应用启动、命令执行和工作区 Agent;这些能力已从当前产品方向移除
## 技术栈
- C++17
- Qt 6 Widgets
- Qt 6 Network
- Qt 6 Multimedia
- CMake
- PNG 图片序列帧
- JSON 配置文件
- Windows 10 / Windows 11 优先
## 构建
推荐环境:
- Qt 6.5.3
- CMake 3.20+
- Ninja
- MinGW 11.2.0 或已配置好的 Qt MSVC Kit
MinGW 示例:
```powershell
cmake -S . -B build/mingw-debug -G Ninja `
-DCMAKE_BUILD_TYPE=Debug `
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 `
-DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe `
-DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe
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 图标需要重新构建后生效。
## 角色包
当前默认角色包位于:
```text
resources/characters/shiroko/
```
内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录:
```text
QStandardPaths::AppDataLocation/characters/<characterId>/
```
角色包基本结构:
```text
resources/characters/shiroko/
character.json
preview.png
idle/
talk/
think/
sleep/
happy/
drag/
error/
```
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。
运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。
角色导入:
- 只支持导入本地文件夹,不支持 zip
- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件
- 验证通过后复制到用户数据目录
- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名
- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾
- 用户角色同名时会询问是否覆盖
- 内置角色 id 不能被导入包覆盖
- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG
- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入
- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作
- 设置页支持导出当前选择的角色到用户选择目录,内置角色和用户角色都可以导出副本
- 导出目标目录已存在时会二次确认;不确认时不会覆盖
- 设置页支持打开用户角色目录,便于检查导入角色文件
懒加载现状:
- `enableLazyLoad=true` 时,启动阶段只建立状态到帧路径的索引
- 某个状态首次播放时加载该状态的 PNG 帧
- 启动后会在主线程按批次预热常用状态,避免一次性加载全部帧
- 已加载状态按状态级 LRU 策略管理,超过动画缓存上限时卸载非保护状态
- 单轮预热不会反复重新加载刚被 LRU 卸载的状态,避免缓存上限较低时出现加载/卸载循环
- 隐藏到托盘时可释放非保护动画缓存
- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为
## 天气查询
当前支持通过聊天输入查询基础天气,例如:
```text
西安天气怎么样
明天西安天气怎么样
后天纽约天气
未来三天北京天气
今天天气怎么样
```
天气查询使用 Open-Meteo Forecast API 和 Open-Meteo Geocoding API,不需要 API Key。回复采用稳定模板,不依赖 AI 润色。支持当前/今天、明天、后天和未来 1-3 天基础天气;空气质量、天气预警、天气提醒联动、复杂小时级降雨判断和穿衣指数暂不支持。
当前 v1 推荐使用市级城市名。用户仍可输入天气源可识别的中文或英文地名,但区县、乡镇、街道不保证精确识别,可能无法匹配,或被匹配到上级/同名城市。当前会读取天气源返回的前 5 个地理编码候选;如果存在同名城市,仍使用第一个结果查询天气,并在回复中提示当前使用的城市和其他候选。
城市来源优先级:
- 用户输入明确城市
- 设置页“天气”中的默认城市
- 默认城市为空且开启开关时,根据公网 IP 定位判断城市
当使用设置页默认城市或公网 IP 定位时,回复会明确说明城市来源。公网 IP 定位使用 ipapi.co;如不希望请求该服务,可在设置页关闭“无默认城市时根据公网 IP 判断城市”。
设置页“天气”中提供“测试默认城市”按钮,只验证输入框当前城市会匹配到哪个城市、行政区和国家,不自动保存配置;最终保存仍由设置页 Save 控制。
天气配置保存到:
```text
QStandardPaths::AppConfigLocation/weather_config.json
```
天气配置损坏时会备份为:
```text
weather_config.broken.yyyyMMdd-HHmmss.json
```
天气查询由独立 `src/weather/` 模块处理,`PetWindow` 只负责展示查询状态和结果。AI 正在请求或流式回复时不会启动天气查询,以避免覆盖 AI 气泡。
## 聊天历史管理
聊天记录默认只保存在内存中。设置页“聊天”中开启“保存聊天记录到本地”后,会保存到:
```text
QStandardPaths::AppConfigLocation/conversation_history.json
```
历史管理能力:
- 支持按关键词搜索当前聊天历史
- 支持按 Provider 和模型筛选;旧历史缺少元数据时会按未知处理
- 支持导出当前筛选结果为 Markdown
- 支持导出当前筛选结果为 JSON
- 清空聊天记录需要确认,并会清空当前内存和本地保存记录
- 导出内容不包含 API Key 或 AI 配置
- 日志不会记录完整聊天正文
## 定时提醒和音效
当前支持通过聊天输入创建一次性和重复本地提醒,例如:
```text
10分钟后提醒我喝水
半小时后提醒我休息
一个半小时后提醒我喝水
明天9点提醒我开会
后天9点提醒我开会
6月3日9点提醒我提交
下周一上午10点提醒我周会
每天9点提醒我打卡
每天提醒我9点打卡
每日晚上8点提醒我吃药
每周一上午10点提醒我周会
每周一提醒我上午10点周会
每星期五下午3点提醒我提交周报
每月3号9点提醒我交报告
每月3号提醒我9点交报告
提醒列表
取消喝水提醒
```
提醒数据保存到:
```text
QStandardPaths::AppConfigLocation/reminders.json
```
提醒数据使用原子写入,写入失败时不会触发到点 UI,也不会覆盖旧的有效提醒文件。已触发和已取消记录会写入 `finishedAt`;旧版数据没有该字段时按 `remindAt` 兼容读取。
提醒调度保留最近提醒的精确 timer,同时每 60 秒做一次兜底扫描;程序显示、外部激活或系统睡眠唤醒后,都会重新检查已到期 pending 提醒。
设置页支持编辑 pending 提醒的标题、下一次时间和重复规则;已触发/已取消历史只读。历史记录默认只保留最近 20 天,设置页“清理20天前历史”只删除超过 20 天的已触发/已取消记录,不影响 pending。
提醒文件损坏时会备份为:
```text
reminders.broken.yyyyMMdd-HHmmss.json
```
内置提醒音效位于:
```text
resources/sounds/reminders/
```
用户导入的提醒音效保存到:
```text
QStandardPaths::AppDataLocation/sounds/reminders/
```
音效规则:
- 默认音效为 `reminder_default`
- 提醒触发时使用当前设置页选择的全局音效;修改音效后对所有未触发提醒立即生效
- 内置音效可切换、可试听,但不能在设置页删除
- 用户音效只支持导入 PCM wav
- 用户导入音效可切换、可试听、可删除
- 删除当前用户音效后会回退到 `reminder_default`
触发规则:
- 桌宠可见时显示气泡,不发系统通知
- 桌宠隐藏时发 Windows 托盘通知,不在下次显示时补气泡
- AI 正在请求或流式回复时,按隐藏场景处理:播放音效并发 Windows 托盘通知,不显示气泡
- 托盘或系统通知后端不可用时只记录日志,不补气泡
- 用户拖动中不打断 `drag`,拖动结束后显示气泡
- 多条提醒同时触发时,可见状态下会按队列逐条展示
- 桌宠可见触发时显示 `知道了` 和 `5分钟后再提醒`
- `5分钟后再提醒` 会创建一条新的一次性提醒,不影响原重复规则
- 重复提醒支持 `每天 / 每周 / 每月`;`工作日 / 每两天 / 每月最后一天 / 自定义间隔 / 农历` 等复杂规则暂不支持
- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份
- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除
## 联网模式
联网模式不是搜索引擎聚合器。用户在输入框打开“联网”开关后,普通聊天会进入当前 AI Provider 的原生联网能力:
- OpenAI 官方 Provider:使用 Responses API Web Search。
- Google Gemini Provider:使用 Gemini Google Search grounding。
- DeepSeek 官方 API:当前不提供托管联网搜索工具,显示“不支持原生联网”。
- Custom / 第三方 OpenAI-Compatible:默认无法确认联网能力,不会尝试旧搜索页抓取。
- 输入框“联网”开关在启用“记住输入框联网开关状态”时会随用户切换立即保存,不依赖发送消息。
配置保存到:
```text
QStandardPaths::AppConfigLocation/web_config.json
```
交互规则:
- 联网开关默认关闭,避免隐私和费用意外。
- 设置页“联网模式”只显示能力状态、开关记忆、默认开关、超时、来源展示和测试联网模式。
- 当前模型不支持联网时,输入框打开联网后会明确提示,不发起伪联网请求。
- 支持联网的 Provider 返回来源时会展示来源;模型判断无需联网时允许无来源,并显示“模型未使用联网来源”。
- 旧 `search_config.json` 已废弃,新版不会读取、迁移或写入。
后续可扩展:
- 更多 AI Provider 的原生联网适配。
- 结构化搜索 API 或自建联网后端。
- 更细粒度的来源证据摘录和引用 UI。
## 当前能力边界
为保持桌宠产品聚焦,当前版本已移除聊天驱动的本地文件操作、应用启动和工作区 Agent。相关聊天内容只会进入普通 AI 对话或现有的提醒/天气意图,不会弹出本地路径选择器、启动程序、扫描项目或执行命令。
应用自身的“开机自启动”仍保留,由设置页和安装器选项管理。旧版 `launcher_config.json` 与 `app_config.json` 中的 `agent` 字段不会再读取或写入,但程序不会主动删除用户目录中的旧文件。
## 配置和日志
应用配置保存到 Qt 标准配置目录:
```text
QStandardPaths::AppConfigLocation/app_config.json
```
配置损坏时会备份为带时间戳的文件:
```text
app_config.broken.yyyyMMdd-HHmmss.json
ai_config.broken.yyyyMMdd-HHmmss.json
conversation_history.broken.yyyyMMdd-HHmmss.json
weather_config.broken.yyyyMMdd-HHmmss.json
web_config.broken.yyyyMMdd-HHmmss.json
```
日志输出到文件,不输出到控制台:
```text
QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
```
如果 Qt 无法取得标准配置目录,则回退到当前工作目录下的 `logs/QtDesktopPet.log`。
日志轮转规则:
- 单个日志文件超过 2MB 时轮转
- 最多保留 3 个旧日志文件
- 文件名为 `QtDesktopPet.log.1`、`QtDesktopPet.log.2`、`QtDesktopPet.log.3`
## 发布打包
仓库提供 Windows Release 打包脚本。脚本不负责 CMake 构建,先手动完成 Release 构建,再把 exe 路径传给脚本:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 -ExePath build/release/QtDesktopPet.exe
```
脚本会生成目录包和 zip:
```text
dist/QtDesktopPet-<version>-windows-x64/
dist/QtDesktopPet-<version>-windows-x64.zip
```
发布目录包含:
```text
QtDesktopPet.exe
Qt runtime
MSVC x64 runtime(MSVC 构建时采用应用本地部署)
resources/characters/
resources/icons/
resources/sounds/
LICENSE
README.md
```
脚本会调用 `windeployqt.exe` 收集 Qt 运行库。若当前 PATH 找不到 `windeployqt.exe`,需要指定 Qt bin 目录下的工具路径:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe
```
对于 MSVC 构建,脚本会从 Visual Studio Redistributable 目录复制 x64 CRT DLL,确保 ZIP 和安装版无需预装 Visual C++ 运行库即可启动。建议从 Visual Studio Developer PowerShell/Command Prompt 运行;也可以显式传入运行库目录:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe `
-MsvcRuntimeDir "D:\VisualStudio\VC\Redist\MSVC\<version>\x64\Microsoft.VC143.CRT"
```
MSVC 运行库目录缺失或不完整时,脚本会在删除旧发布目录前终止,不生成可能无法在干净电脑启动的产物。
生成 Inno Setup 安装器:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-BuildInstaller
```
安装器最终默认输出到项目根目录:
```text
QtDesktopPet-<version>-windows-x64-setup.exe
```
脚本会先让 Inno Setup 输出到当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再把最终安装包复制回项目根目录,避免中文项目路径下出现 `EndUpdateResource failed (5)`。如果需要改变最终安装包目录,可传入 `-InstallerOutputDir`:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-BuildInstaller `
-InstallerOutputDir D:\ReleaseOutput
```
如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。
本地生成的安装包也可以集中放到 `release_packages/`:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-BuildInstaller `
-InstallerOutputDir release_packages
```
`dist/` 和 `release_packages/` 都是本地发布产物目录,不进入 Git。
脚本默认优先查找:
```text
D:\Inno Setup 7\ISCC.exe
D:\Inno Setup 6\ISCC.exe
C:\Program Files (x86)\Inno Setup 7\ISCC.exe
C:\Program Files (x86)\Inno Setup 6\ISCC.exe
```
如果 Inno Setup 安装在其他位置,可传入 `-InnoCompilerPath`。
安装器页面提供可选项:
- 创建桌面快捷方式,默认不勾选。
- 开机自启动,默认不勾选;勾选后写入当前用户 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`,不需要管理员权限。
应用内设置页的“应用设置”页也提供“开机自启动”开关,保存后会立即写入或移除同一个当前用户 Run 项。卸载时会清理该 Run 项,避免卸载后残留自启动入口。
安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 QtDesktopPet 数据目录。
## 开发诊断
仓库提供开发用性能采样脚本,不进入普通用户发布包:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m
```
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到:
```text
reports/perf/
```
`reports/perf/` 已加入 `.gitignore`。稳定性检查记录模板见:
```text
docs/performance_stability_check.md
```
内部核心逻辑测试、静态检查、性能报告和长期测试记录说明见:
```text
internal/qa/README.md
docs/test-records/README.md
```
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。
## AI 配置和聊天
当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口:
- OpenAI
- Google
- DeepSeek
- Custom
其中 OpenAI、DeepSeek、Custom 走 OpenAI Compatible 形式配置;Google 走 Gemini 原生 REST 接口。旧版保存过的已废弃 Provider 配置会在读取 AI 配置时清理,废弃 Provider 被选中时会回退为 `custom`。
已支持:
- 用户自定义 Base URL
- 用户自定义 API Key
- 用户自定义 Model
- 用户自定义 Path
- 超时、Temperature、Max Tokens
- 流式输出
- Google Gemini `generateContent` / `streamGenerateContent`
- 请求中切换 `think`
- 收到首段输出后切换并保持 `talk`
- 失败时切换 `error`
- API Key 不写入日志,不在错误提示中完整显示
- 对话历史面板记录用户消息和 AI 最终回复
AI 测试入口已从角色右键菜单移除,并迁移到设置页。
## 隐私说明
程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。
日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header、完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要。
当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整。
## 素材版权说明
源码采用 MIT License。
角色素材、图片、图标等资源需要单独确认授权。当前 `shiroko` 素材用于桌宠加载器、动画和状态切换测试;在公开发布或正式分发前,需要确认素材的版权、再分发权限和适用范围。
用户导入或替换的素材,其版权责任由用户自行承担。
## 许可证
项目源码使用 MIT License,见 [LICENSE](LICENSE)。
+171 -277
View File
@@ -1,215 +1,117 @@
# QtDesktopPet # QtDesktopPet
QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目,当前已具备多状态 PNG 帧动画、托盘控制、角色包导入与切换、用户自定义大模型对话、设置面板和 Windows 发布打包能力。项目现阶段重点是完善稳定性、性能回归、角色管理和发布体验。 一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、AI 原生联网、本地提醒和天气查询等能力。
## 当前状态 > 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。
已实现: ![Preview](resources/characters/shiroko/preview.png)
- 透明无边框桌宠窗口 ## Features
- 鼠标拖动
- 右键菜单退出和状态测试
- 置顶切换
- `resources/characters/shiroko` 默认角色包读取
- PNG 序列帧动画播放
- `idle` / `talk` / `think` / `sleep` / `happy` / `drag` / `error` 状态
- 托盘显示、隐藏、退出
- 单实例限制,重复启动会唤醒已有实例
- 隐藏时暂停动画,显示时恢复动画
- 保存窗口位置、置顶、缩放和性能设置
- 文件日志和基础轮转
- 设置窗口按当前屏幕居中弹出
- 应用设置:缩放、性能模式、隐藏暂停、懒加载
- 状态级动画预热和 LRU 缓存卸载
- AI Provider 分组配置
- 设置页内 AI 连通性测试
- Windows DPAPI 加密保存 API Key
- 非 Windows 环境经用户确认后明文保存 API Key
- OpenAI Compatible 聊天请求
- SSE 流式输出
- 聊天输入框
- AI 回复气泡
- 对话历史面板
- 内存历史上限和可选本地历史保存
- AI 请求取消和对话清空
- Google Gemini 原生聊天请求
- 角色文件夹导入和角色切换
- 删除用户导入角色
- Windows 发布打包脚本和 Inno Setup 安装器脚本
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
尚未实现: - 透明无边框桌宠窗口,支持拖动、置顶、托盘隐藏和单实例唤醒。
- 多状态 PNG 序列帧动画:`idle`、`talk`、`think`、`sleep`、`happy`、`drag`、`error`。
- 角色包导入、切换、导出和用户角色目录管理。
- AI 对话:
- OpenAI-compatible API
- Google Gemini API
- DeepSeek / Custom Provider 配置
- 流式输出、请求取消、对话历史面板
- Windows DPAPI 加密保存 API Key
- 联网模式:
- 输入框“联网”开关
- OpenAI 官方 Responses API Web Search
- Gemini Google Search grounding
- DeepSeek / Custom 默认提示不支持或无法确认原生联网
- 本地提醒:
- 一次性提醒
- 每天 / 每周 / 每月重复提醒
- 提醒音效导入、试听、切换
- 桌宠可见时气泡提示,隐藏或 AI 忙时系统通知
- 天气查询:
- Open-Meteo Forecast API
- Open-Meteo Geocoding API
- 默认城市、可信同名候选提示,以及常用海外城市中文名解析
- 公网 IP 定位依次尝试 ipapi.co 和 ipwho.is,单个服务故障时自动切换
- Windows 发布脚本和 Inno Setup 安装器脚本。
- 角色导出和更完整的管理界面 ## Platform
- 对话历史导出/管理界面
- 长期性能压测记录
- 发布包实机安装/卸载验证
## 技术栈 当前主要目标平台是 Windows 10 / Windows 11。
项目中已有部分跨平台基础代码,但托盘通知、开机自启动和安装器体验目前按 Windows 优先实现。
## Tech Stack
- C++17 - C++17
- Qt 6 Widgets - Qt 6 Widgets
- Qt 6 Network - Qt 6 Network
- Qt 6 Multimedia
- CMake - CMake
- PNG 图片序列帧 - JSON 配置
- JSON 配置文件 - PNG 序列帧动画
- Windows 10 / Windows 11 优先 - Inno Setup
## 构建 ## Repository Layout
推荐环境: ```text
.
├── CMakeLists.txt
├── main.cpp
├── installer/ # Inno Setup script
├── resources/
│ ├── characters/ # Built-in character packages
│ ├── icons/
│ └── sounds/
├── src/
│ ├── ai/ # AI providers and conversation state
│ ├── assistant/ # Intent routing and command dispatch
│ ├── character/ # Character package loading and animation
│ ├── config/ # Config persistence
│ ├── notification/ # Notification dispatch
│ ├── reminder/ # Reminder parser/store/scheduler/sounds
│ ├── state/ # Pet state machine
│ ├── system/ # Windows startup integration
│ ├── tray/ # System tray controller
│ ├── ui/ # Widgets and main pet window
│ ├── util/
│ ├── weather/
│ └── web/ # AI-native web mode
└── tools/ # Packaging and public-export scripts
```
- Qt 6.5.3 ## Build
Recommended environment:
- Qt 6.5+
- CMake 3.20+ - CMake 3.20+
- Ninja - Ninja
- MinGW 11.2.0 或已配置好的 Qt MSVC Kit - MinGW 11.2.0 or a configured Qt MSVC Kit
MinGW 示例: Example with MinGW:
```powershell ```powershell
cmake -S . -B build/mingw-debug -G Ninja ` cmake -S . -B build/mingw-release -G Ninja `
-DCMAKE_BUILD_TYPE=Debug ` -DCMAKE_BUILD_TYPE=Release `
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 ` -DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 `
-DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe ` -DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe `
-DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe -DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe
cmake --build build/mingw-debug cmake --build build/mingw-release
``` ```
如果使用 Qt Creator,也可以直接打开根目录 `CMakeLists.txt`,选择合适的 Qt Kit 后构建。 Qt Creator users can open `CMakeLists.txt` directly and build with a matching Qt Kit.
## 应用图标 ## Package
当前应用图标位于: After building a Release executable, package it with:
```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 图标需要重新构建后生效。
## 角色包
当前默认角色包位于:
```text
resources/characters/shiroko/
```
内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录:
```text
QStandardPaths::AppDataLocation/characters/<characterId>/
```
角色包基本结构:
```text
resources/characters/shiroko/
character.json
preview.png
idle/
talk/
think/
sleep/
happy/
drag/
error/
```
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。
运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。
角色导入:
- 只支持导入本地文件夹,不支持 zip
- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件
- 验证通过后复制到用户数据目录
- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名
- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾
- 用户角色同名时会询问是否覆盖
- 内置角色 id 不能被导入包覆盖
- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG
- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入
- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作
懒加载现状:
- `enableLazyLoad=true` 时,启动阶段只建立状态到帧路径的索引
- 某个状态首次播放时加载该状态的 PNG 帧
- 启动后会在主线程按批次预热常用状态,避免一次性加载全部帧
- 已加载状态按状态级 LRU 策略管理,超过动画缓存上限时卸载非保护状态
- 单轮预热不会反复重新加载刚被 LRU 卸载的状态,避免缓存上限较低时出现加载/卸载循环
- 隐藏到托盘时可释放非保护动画缓存
- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为
## 配置和日志
应用配置保存到 Qt 标准配置目录:
```text
QStandardPaths::AppConfigLocation/app_config.json
```
配置损坏时会备份为带时间戳的文件:
```text
app_config.broken.yyyyMMdd-HHmmss.json
ai_config.broken.yyyyMMdd-HHmmss.json
conversation_history.broken.yyyyMMdd-HHmmss.json
```
日志输出到文件,不输出到控制台:
```text
QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
```
如果 Qt 无法取得标准配置目录,则回退到当前工作目录下的 `logs/QtDesktopPet.log`。
日志轮转规则:
- 单个日志文件超过 2MB 时轮转
- 最多保留 3 个旧日志文件
- 文件名为 `QtDesktopPet.log.1`、`QtDesktopPet.log.2`、`QtDesktopPet.log.3`
## 发布打包
仓库提供 Windows Release 打包脚本。脚本不负责 CMake 构建,先手动完成 Release 构建,再把 exe 路径传给脚本:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 -ExePath build/release/QtDesktopPet.exe
```
脚本会生成目录包和 zip:
```text
dist/QtDesktopPet-<version>-windows-x64/
dist/QtDesktopPet-<version>-windows-x64.zip
```
发布目录包含:
```text
QtDesktopPet.exe
Qt runtime
resources/characters/
resources/icons/
LICENSE
README.md
```
脚本会调用 `windeployqt.exe` 收集 Qt 运行库。若当前 PATH 找不到 `windeployqt.exe`,需要指定 Qt bin 目录下的工具路径:
```powershell ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe ` -ExePath build/release/QtDesktopPet.exe
-WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe
``` ```
生成 Inno Setup 安装器: To generate the Inno Setup installer:
```powershell ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
@@ -217,113 +119,105 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-BuildInstaller -BuildInstaller
``` ```
安装器最终默认输出到项目根目录: For MSVC builds, run the package script from a Visual Studio developer shell or pass `-MsvcRuntimeDir`. The script deploys the x64 MSVC runtime beside the application and fails early when the required runtime files are unavailable.
The installer supports optional desktop shortcut creation and optional Windows startup launch. Both are disabled by default.
## Runtime Data
Runtime configuration and user data are stored under Qt standard user directories:
- `QStandardPaths::AppConfigLocation`
- `QStandardPaths::AppDataLocation`
Examples:
- AI config: `ai_config.json`
- App config: `app_config.json`
- Conversation history: `conversation_history.json`
- Reminders: `reminders.json`
- Weather config: `weather_config.json`
- Web mode config: `web_config.json`
The app writes rotating logs under:
```text ```text
QtDesktopPet-<version>-windows-x64-setup.exe QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
``` ```
脚本会先让 Inno Setup 输出到当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再把最终安装包复制回项目根目录,避免中文项目路径下出现 `EndUpdateResource failed (5)`。如果需要改变最终安装包目录,可传入 `-InstallerOutputDir`: ## AI And Privacy
QtDesktopPet only sends chat content to the AI endpoint configured by the user.
Important notes:
- API keys are not logged.
- Authorization headers are not logged.
- Full user messages and full error bodies should not be logged.
- On Windows, API keys are saved with DPAPI when available.
- Third-party compatible APIs and proxy services are controlled by the user; this project cannot guarantee their privacy behavior.
## Safety Boundaries
- The application does not read, search, create, modify, move, or delete local files through chat.
- It does not launch local applications or convert chat text into shell commands or scripts.
- Windows startup integration only manages this application's current-user `Run` entry.
- AI and web requests are sent only to providers configured by the user.
## Web Mode
Web mode is an AI-native conversation feature, not a search engine scraper.
Supported:
- OpenAI official API with Responses API Web Search.
- Google Gemini API with Google Search grounding.
Not treated as supported native web access:
- DeepSeek official API
- Custom OpenAI-compatible endpoints
- Third-party relay APIs
Unsupported providers show an explicit message instead of falling back to unreliable search-page scraping.
## Character Packages
Built-in characters are placed under:
```text
resources/characters/<characterId>/
```
A character package contains:
```text
character.json
preview.png
idle/
talk/
think/
sleep/
happy/
drag/
error/
```
User-imported characters are copied to the user's app data directory instead of the installation directory.
## Public Export
This development workspace may contain internal planning and test documents. To create a clean public GitHub export, use:
```powershell ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` powershell -NoProfile -ExecutionPolicy Bypass -File tools/prepare_github_export.ps1 `
-ExePath build/release/QtDesktopPet.exe ` -OutputDir D:\DesktopPet-github-export
-BuildInstaller `
-InstallerOutputDir D:\ReleaseOutput
``` ```
如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。 The export excludes internal docs, reports, build outputs, release packages, local config, logs and Git metadata.
本地生成的安装包也可以集中放到 `release_packages/`: ## License
```powershell Source code is released under the MIT License. See [LICENSE](LICENSE).
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
-ExePath build/release/QtDesktopPet.exe `
-BuildInstaller `
-InstallerOutputDir release_packages
```
`dist/` 和 `release_packages/` 都是本地发布产物目录,不进入 Git。 Character art, icons, sounds and other media assets may have separate copyright requirements. Confirm asset licensing before public redistribution.
脚本默认优先查找:
```text
D:\Inno Setup 7\ISCC.exe
D:\Inno Setup 6\ISCC.exe
C:\Program Files (x86)\Inno Setup 7\ISCC.exe
C:\Program Files (x86)\Inno Setup 6\ISCC.exe
```
如果 Inno Setup 安装在其他位置,可传入 `-InnoCompilerPath`。
安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 QtDesktopPet 数据目录。
## 开发诊断
仓库提供开发用性能采样脚本,不进入普通用户发布包:
```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/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。
## AI 配置和聊天
当前正式聊天支持 OpenAI Compatible 和 Google Gemini 两类协议。已提供以下 Provider 配置入口:
- OpenAI
- Google
- DeepSeek
- Custom
其中 OpenAI、DeepSeek、Custom 走 OpenAI Compatible 形式配置;Google 走 Gemini 原生 REST 接口。旧版保存过的已废弃 Provider 配置会在读取 AI 配置时清理,废弃 Provider 被选中时会回退为 `custom`。
已支持:
- 用户自定义 Base URL
- 用户自定义 API Key
- 用户自定义 Model
- 用户自定义 Path
- 超时、Temperature、Max Tokens
- 流式输出
- Google Gemini `generateContent` / `streamGenerateContent`
- 请求中切换 `think`
- 收到首段输出后切换并保持 `talk`
- 失败时切换 `error`
- API Key 不写入日志,不在错误提示中完整显示
- 对话历史面板记录用户消息和 AI 最终回复
AI 测试入口已从角色右键菜单移除,并迁移到设置页。
## 隐私说明
程序只会把用户消息发送到用户自己配置的接口。用户需要自行判断第三方代理、中转服务或自建服务是否可信。项目不会默认承诺第三方接口的隐私安全。
日志会记录请求诊断信息,例如 Provider、Base URL 主机、Path、HTTP 状态码、响应大小、错误摘要等;日志不应记录完整 API Key、Authorization Header、完整消息正文或完整错误响应正文。错误响应只保留脱敏摘要。
当前对话历史默认保存在内存中,已支持内存历史上限、请求上下文截取和可选本地历史保存;相关上限可在设置页调整。
## 素材版权说明
源码采用 MIT License。
角色素材、图片、图标等资源需要单独确认授权。当前 `shiroko` 素材用于桌宠加载器、动画和状态切换测试;在公开发布或正式分发前,需要确认素材的版权、再分发权限和适用范围。
用户导入或替换的素材,其版权责任由用户自行承担。
## 许可证
项目源码使用 MIT License,见 [LICENSE](LICENSE)。
@@ -0,0 +1,70 @@
# QtDesktopPet 当前路线与结构收口
更新时间:2026-07-16
## 产品定位
QtDesktopPet 当前定位为轻量 Windows 桌面宠物,核心体验是角色动画、简短 AI 对话、AI 原生联网、本地提醒和天气查询。产品不再承担本地文件管理、应用启动或代码工作区 Agent 职责。
## 已落地模块
| 模块 | 主要实现 | 当前状态 |
| --- | --- | --- |
| 桌宠与动画 | `src/ui/`、`src/character/`、`src/state/` | 透明窗口、拖动、托盘、单实例、多状态动画、懒加载和缓存管理已落地 |
| AI 对话 | `src/ai/` | OpenAI-compatible、Gemini、DeepSeek/Custom 配置、流式输出、取消和历史管理已落地 |
| 联网模式 | `src/web/` | OpenAI 官方 Web Search 与 Gemini Google Search grounding 已落地;不支持 Provider 明确提示 |
| 提醒 | `src/reminder/`、`src/notification/` | 一次性和日/周/月重复、编辑 pending、20 天历史、音效、通知和兜底调度已落地 |
| 天气 | `src/weather/` | Open-Meteo、默认城市、IP fallback、多候选提示和默认城市测试已落地 |
| 角色管理 | `src/character/`、设置页 | 导入、验证、切换、删除用户角色、导出和打开用户角色目录已落地 |
| 配置与发布 | `src/config/`、`src/system/`、`tools/`、`installer/` | 原子/兼容配置、开机自启动、打包和安装脚本已落地 |
## 已移除模块
以下模块已从源码、CMake、意图分发、设置页和托盘入口中删除:
- 聊天驱动的本地文件操作。
- 本地应用启动与应用登记。
- 本地工作区 Agent、独立 Agent 窗口和 `Alt+A` 全局快捷键。
删除原因是这些能力显著扩大安全边界和维护面,并使桌宠产品偏离轻量助手定位。应用自身的“开机自启动”不属于应用启动模块,继续保留。
兼容策略:程序不主动删除用户目录中的旧 `launcher_config.json`;旧 `app_config.json` 中的 `agent` 对象会被忽略,后续保存时不再写回。
## 当前意图边界
聊天意图优先级固定为:
```text
Reminder > Weather > Chat/WebChat
```
- 含“提醒”等明确提醒表达时进入提醒模块。
- 含天气、气温、降雨等表达时进入天气模块。
- 其余内容进入普通 AI 对话;输入框联网开关开启且 Provider 支持时进入联网对话。
- “读取文件”“打开 Codex”“分析这个项目”等文本不会触发本地系统能力。
## 下一阶段建议
优先完成稳定性和发布收口,不再横向扩展高权限本地自动化:
1. 完成全量手动回归,重点覆盖提醒调度、联网 Provider 差异、天气失败路径和旧配置兼容。
2. 完成 Release 包、安装、卸载、开机自启动和无 Qt 开发环境运行验证。
3. 完成静置、隐藏、动画缓存、AI 连续对话、提醒、天气和联网请求的性能采样。
4. 根据实测问题修复稳定性,不新增复杂提醒规则、网页抓取或高权限本地能力。
5. 确认角色、图标、音效素材的公开再分发授权。
## 延期或非目标
- 语音输入输出、Live2D、GIF/视频角色。
- 插件系统、云同步、自动更新、长期记忆和角色市场。
- 空气质量、天气预警、国内天气 Provider 和天气提醒联动。
- 复杂提醒规则、跨平台通知和自定义稍后提醒间隔。
- 网页全文抓取、搜索引擎页面解析、自建结构化搜索后端。
- 本地文件访问、应用启动、命令执行和工作区 Agent。
## 验收策略
- 编译和运行验证由用户手动执行。
- 统一测试入口见 `docs/QtDesktopPet_测试清单与验收标准.md`。
- 性能与发布验证入口见 `docs/performance_stability_check.md`。
- 每次能力调整后必须同步 README、测试清单、CMake 和设置页,避免残留不可达代码。
@@ -0,0 +1,214 @@
# QtDesktopPet 测试清单与验收标准
本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。
每次实际执行结果保存到 `docs/test-records/`,不得直接用某次结果覆盖本验收标准。内部自动化入口见 `internal/qa/README.md`。
## 验收记录格式
每个用例建议记录:
```text
测试日期:
Git commit:
构建类型:Debug / Release
操作系统:
Qt 版本:
测试人:
结论:通过 / 不通过 / 阻塞
实际结果:
备注:
```
## 总体验收标准
- 程序不崩溃、不卡死,UI 主线程不被网络、文件或 AI 请求阻塞。
- 日志不包含完整 API Key、Authorization、完整用户消息正文、完整错误响应正文或完整搜索词。
- 设置页保存后立即生效,重启后配置仍能读取。
- 所有损坏 JSON 配置都应备份为 `.broken.yyyyMMdd-HHmmss.json` 后回退默认或忽略损坏数据。
- 角色导入/导出、音效导入和历史导出只能使用用户明确选择的路径;覆盖或删除用户数据时必须确认。
- 不运行 CMake 的自动验证;构建和运行由用户手动完成。
## 基础窗口与托盘
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| 启动 | 启动 `QtDesktopPet.exe` | 桌宠显示,透明无边框,无控制台窗口,日志创建成功 | TODO |
| 拖动 | 按住桌宠拖动后松开 | 拖动时进入 `drag`,松开后恢复合理状态 | TODO |
| 置顶 | 右键切换置顶 | 置顶状态立即变化,重启后保持 | TODO |
| 隐藏托盘 | 托盘菜单隐藏/显示 | 隐藏后动画暂停,显示后恢复 | TODO |
| 单实例 | 已运行时再次启动 exe | 只有一个进程,已有实例被唤醒 | TODO |
| 设置页居中 | 多屏或重复打开设置页 | 设置页出现在当前屏幕可用区域内并置前 | TODO |
| 应用设置持久化 | 修改缩放、性能模式、隐藏暂停、懒加载和缓存设置后重启 | 设置立即生效,重启后保持,动画和窗口尺寸无异常 | TODO |
| 开机自启动 | 在应用页依次开启、重启检查、再关闭 | 开启时写入当前用户 Run 项且路径正确;关闭时移除;默认关闭 | TODO |
## 角色管理
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| 切换角色 | 设置页选择角色并保存 | 桌宠立即切换,不依赖重启 | TODO |
| 导入合法角色 | 导入有效角色文件夹 | 验证通过后复制到用户数据目录,可选择使用 | TODO |
| 导入非法角色 | 导入缺少 `character.json` 或无 idle 的角色 | 弹出错误,不复制、不覆盖 | TODO |
| 覆盖用户角色 | 导入同 id 用户角色 | 弹出覆盖确认,取消则不操作 | TODO |
| 内置角色保护 | 尝试删除内置角色 | 提示不能删除,不做文件操作 | TODO |
| 删除用户角色 | 删除用户导入角色 | 二次确认后删除用户角色目录;当前角色被删时回退默认 | TODO |
| 导出角色 | 选择任意角色并导出到目录 | 目标生成 `<characterId>/` 副本;目标存在时必须确认 | TODO |
| 打开目录 | 点击打开用户角色目录 | 系统文件管理器打开用户角色目录 | TODO |
## AI 与聊天历史
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| AI 配置保存 | 设置 Provider/Base URL/API Key/Model 并保存 | 配置保存,API Key 按平台策略加密或确认明文 | TODO |
| 连接测试 | 点击测试连接 | 成功显示成功;错误配置显示明确错误 | TODO |
| 普通聊天 | 发送短消息 | `think` 后进入 `talk`,气泡显示回复 | TODO |
| 流式回复 | 启用流式并发送消息 | 首段前保持 `think`,输出中保持 `talk` | TODO |
| AI 忙 | 回复中再次发送聊天/天气/联网请求 | 不启动第二个请求,显示忙提示 | TODO |
| 取消 AI | 右键取消 AI 请求 | 请求取消,状态恢复,气泡提示取消 | TODO |
| 历史保存 | 开启本地保存后聊天 | 重启后历史可读取 | TODO |
| 历史搜索 | 在设置页按关键词搜索 | 只显示匹配记录 | TODO |
| 历史筛选 | 按 Provider/模型筛选 | 只显示对应元数据记录;旧记录按未知处理 | TODO |
| 导出 Markdown | 导出筛选结果为 `.md` | 文件包含角色、时间、Provider/模型和正文,不含 API Key | TODO |
| 导出 JSON | 导出筛选结果为 `.json` | JSON 可解析,包含 messages 数组,不含 API Key | TODO |
| 清空历史 | 点击清空聊天记录并确认 | 内存和本地历史清空,面板刷新 | TODO |
| 关闭历史保存 | 关闭本地保存后聊天并重启 | 本轮消息只在内存存在,重启后不写入本地历史 | TODO |
## 定时提醒
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| 一次性提醒 | `10分钟后提醒我喝水` | 创建 pending 提醒,显示标题和时间 | TODO |
| 中文相对时间 | `半小时后提醒我休息`、`一个半小时后提醒我喝水` | 时间解析正确 | TODO |
| 绝对日期 | `明天9点提醒我开会`、`6月3日9点提醒我提交` | 未明确日期且当天已过时顺延 | TODO |
| 重复提醒 | `每天9点提醒我打卡`、`每周一上午10点提醒我周会`、`每月3号9点提醒我交报告` | 创建 daily/weekly/monthly | TODO |
| 不支持规则 | `工作日9点提醒我打卡`、`农历初一提醒我` | 明确提示暂不支持,不创建一次性误提醒 | TODO |
| 提醒列表 | `提醒列表` | 只列出 pending 提醒 | TODO |
| 聊天取消 | `取消喝水提醒` | 唯一匹配时标记 canceled;多匹配提示去设置页处理 | TODO |
| 设置页编辑 | 编辑 pending 标题/时间/重复规则 | 保存后列表和下一次触发时间更新 | TODO |
| 触发可见 | 到点时桌宠可见且 AI 不忙 | 播放当前音效,显示气泡和操作区,不发系统通知 | TODO |
| 触发隐藏 | 桌宠隐藏时到点 | 播放当前音效,只发 Windows 托盘通知,不补气泡 | TODO |
| AI 忙触发 | AI 请求或流式回复中到点 | 播放当前音效,发系统通知,不显示提醒气泡 | TODO |
| 拖动触发 | 拖动中到点 | 不打断 `drag`,松开后按队列显示气泡 | TODO |
| 多提醒同时触发 | 两条同时间提醒 | 可见时逐条队列展示,后一条不覆盖前一条 | TODO |
| 知道了 | 点击 `知道了` | 关闭当前提醒展示,队列进入下一条 | TODO |
| 5分钟后再提醒 | 点击 `5分钟后再提醒` | 创建当前时间 + 5 分钟的一次性 pending,不影响原重复规则 | TODO |
| 历史清理 | 构造 21 天前 triggered/canceled 后清理 | 删除 20 天前历史,保留最近 20 天和 pending | TODO |
| 音效导入 | 导入合法 PCM wav | 可选择、试听、删除 | TODO |
| 内置音效保护 | 选择内置音效点击删除 | 删除按钮禁用或提示不可删除 | TODO |
| 睡眠与时间变化 | 睡眠跨过提醒时间;再分别模拟系统时间向前和向后变化 | 唤醒或向前跳变后补扫一次;向后跳变不提前触发 | TODO |
| 提醒保存失败 | 在隔离测试数据上模拟提醒文件不可写并等待到期 | 保持 pending,不播放、不通知、不显示气泡,后续仍可重试 | TODO |
## 天气查询
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| 明确城市 | `西安天气怎么样` | 返回 Open-Meteo 模板结果 | TODO |
| 明天 | `明天西安天气怎么样` | 返回明天预报 | TODO |
| 后天 | `后天纽约天气` | 返回后天预报 | TODO |
| 未来三天 | `未来三天北京天气` | 返回 1-3 天摘要 | TODO |
| 默认城市 | 设置默认城市后输入 `今天天气怎么样` | 使用默认城市并说明来源 | TODO |
| IP fallback | 清空默认城市并启用 IP 定位 | 使用公网 IP 城市并说明来源 | TODO |
| 禁用 IP fallback | 清空默认城市并关闭 IP 定位 | 提示配置默认城市或在问题中带城市 | TODO |
| 多候选 | 查询同名城市 | 使用首项并提示其他候选 | TODO |
| 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO |
| 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO |
| 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO |
| 天气网络失败 | 断网或阻断天气请求后查询 | 明确提示网络/超时错误,UI 不阻塞,恢复网络后可再次查询 | TODO |
## 已移除本地能力回归
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| 文件操作已移除 | 输入 `读取文件`、`创建 test.txt`、`复制文件` | 不弹文件选择框,不读取或修改本地文件;按普通 AI 对话处理 | TODO |
| 应用启动已移除 | 输入 `打开 Codex`、`启动酷狗音乐` | 不弹 exe 选择框或确认框,不启动任何程序;按普通 AI 对话处理 | TODO |
| 工作区 Agent 已移除 | 输入 `打开工作区`、`分析这个项目`、`含 class 的文件` | 不扫描目录、不弹 Agent 窗口、不访问本地项目;按普通 AI 对话处理 | TODO |
| 菜单入口清理 | 检查桌宠右键菜单和托盘菜单 | 不再出现 `Agent 工作区` 入口 | TODO |
| 设置页清理 | 打开设置页 | 不再出现 `应用启动` 和 `Agent` 页面;应用页仍有 `开机自启动` | TODO |
| 快捷键清理 | 按 `Alt+A` | 不再呼出 Agent 窗口,不影响其他快捷键或输入 | TODO |
| 旧配置兼容 | 用户目录保留旧 `launcher_config.json`,`app_config.json` 保留 `agent` 对象后启动 | 程序不崩溃,不读取旧能力配置;保存应用配置后不再写入 `agent` | TODO |
## 联网模式
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| 开关关闭 | 输入框联网开关关闭,询问 `美国的首都在哪里` | 走普通 AI 对话,不调用 WebChatManager | TODO |
| OpenAI 官方联网 | 使用 OpenAI 官方 Provider,打开联网开关,询问 `Qt 6 最新版本` | 进入联网模式,返回回答;有来源时展示来源 | TODO |
| Gemini 官方联网 | 使用 Google Gemini Provider,打开联网开关,询问 `Qt 6 最新版本` | 进入 Gemini grounding,返回回答;有来源时展示来源 | TODO |
| DeepSeek 不支持 | 使用 DeepSeek Provider,打开联网开关并发送问题 | 不发起伪联网请求,提示 DeepSeek 当前不支持原生联网 | TODO |
| 第三方兼容不确认 | 使用 Custom 或第三方 Base URL,打开联网开关 | 提示当前 AI 配置无法确认联网能力 | TODO |
| 模型未联网 | 支持联网 Provider 回答稳定常识但未返回来源 | 展示回答,并标注模型未使用联网来源 | TODO |
| Web 忙冲突 | 联网请求中再次发送普通聊天或联网聊天 | 不启动第二个请求,提示稍后 | TODO |
| AI 忙冲突 | 普通 AI 回复中打开联网再发送 | 不启动联网请求,提示 AI 回复正在进行 | TODO |
| 设置页状态 | 切换 OpenAI/Gemini/DeepSeek/Custom | “联网模式”页即时显示可用/不可用/无法确认状态 | TODO |
| 设置页测试 | 点击“测试联网模式” | 支持 Provider 发起测试,不支持 Provider 直接显示原因 | TODO |
| 旧失败回归 | 打开联网后询问 `美国的首都在哪里` | 不再返回搜狗/360 帮助、登录、反馈页 | TODO |
| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 前者走提醒;后者可走天气或普通对话,但绝不触发本地文件保存 | TODO |
| 联网开关双向记忆 | 启用记忆后分别执行关到开、开到关,不发送消息直接关闭并重开输入框 | 两个方向都保存最后状态;关闭记忆后使用默认开关值 | TODO |
| 联网网络失败 | 支持 Provider 下模拟断网、超时或服务端错误 | 明确提示失败,状态恢复,可重新发起请求,不泄露完整错误正文 | TODO |
## 配置兼容与隐私
所有损坏测试都必须先备份用户数据,优先使用隔离测试账户或临时配置副本。
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| App 配置损坏 | 写入非法 `app_config.json` 后启动 | 原文件备份为带时间戳的 broken 文件,程序使用默认配置启动 | TODO |
| AI 配置损坏 | 写入非法 `ai_config.json` 后启动 | 备份损坏文件,AI 回退未配置状态,不泄露旧 Key | TODO |
| 天气配置损坏 | 写入非法 `weather_config.json` 后启动 | 备份损坏文件,天气使用默认配置 | TODO |
| 联网配置损坏 | 写入非法 `web_config.json` 后启动 | 备份损坏文件,联网开关使用默认配置 | TODO |
| 提醒数据损坏 | 写入非法 `reminders.json` 后启动 | 备份损坏文件,不崩溃、不触发错误数据 | TODO |
| 历史数据损坏 | 写入非法 `conversation_history.json` 后打开历史 | 备份损坏文件并忽略,历史面板可继续使用 | TODO |
| 日志隐私 | 覆盖正常请求、错误 Key、错误 Base URL、天气和联网失败 | 日志不含 API Key、Authorization、完整消息或完整错误响应 | TODO |
## 自动化验证
内部 QA 与发布构建隔离。用户配置好 Qt 测试构建后执行核心逻辑测试;不需要编译的静态检查可直接执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1
```
当前自动化覆盖意图分发、提醒解析和类型、天气解析/城市别名/IP 定位响应/模板、联网能力检测、聊天历史兼容、配置损坏恢复与 AI 诊断脱敏。静态检查覆盖源码登记、废弃模块残留、默认资源、GitHub 导出隔离、发布包必要输入和安装器关键安全项。真实窗口、托盘通知、音效、外部网络服务、Release 二进制和安装卸载仍按手测执行。
## 发布与性能
| 用例 | 步骤 | 预期结果 | 结论 |
| --- | --- | --- | --- |
| Release 构建 | 用户手动构建 Release | 构建成功,无新增编译错误 | TODO |
| 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、MSVC x64 runtime、resources、README、LICENSE;MSVC 运行库缺失时打包直接失败 | TODO |
| 安装器 | 生成并安装 Inno Setup 包 | 无 Qt 开发环境机器可运行 | TODO |
| 安装器自启动选项 | 安装页面勾选/不勾选开机自启动分别安装 | 勾选时写入当前用户 Run 项;不勾选时不新增自启动项;选项默认不勾选 | TODO |
| 卸载清理自启动 | 已启用开机自启动后卸载 | 当前用户 Run 项被清理,不残留开机启动入口 | TODO |
| 卸载保留数据 | 卸载时选择不删除用户数据 | 配置、角色、历史、日志保留 | TODO |
| 卸载删除数据 | 卸载时选择删除用户数据 | 当前用户 QtDesktopPet 数据目录被删除 | TODO |
| 性能采样 | 按 `performance_stability_check.md` 执行 | CPU/内存无持续异常增长,摘要写入文档 | TODO |
## 静态推演记录
每个模块完成后记录:
```text
模块:
入口:
成功路径:
失败路径:
数据兼容:
UI 状态:
日志隐私:
回归影响:
结论:
```
本轮静态推演明细:
| 模块 | 入口 | 成功路径 | 失败路径 / 兜底 | 风险点 | 结论 |
| --- | --- | --- | --- | --- | --- |
| 天气 | `CommandDispatcher -> PetWindow::handleWeatherChatMessage() -> WeatherManager` | 解析城市和日期,按显式城市 / 默认城市 / IP fallback 定位,请求 Open-Meteo,模板格式化后显示气泡 | AI 忙、天气忙提前拒绝;未知城市、IP 失败、超时、JSON 异常返回明确错误;默认城市测试超时会恢复测试按钮 | 网络不可用、Open-Meteo 多候选首项可能不符合预期 | v1/v1.2/v1.3 收口完成,区县精确定位和空气质量等保持延期 |
| 提醒 | `CommandDispatcher -> ReminderCommandHandler -> ReminderManager` | 创建/查询/取消一次性和 daily/weekly/monthly;到点先保存 JSON,再播放音效和展示/通知 | 保存失败回滚内存,不播放、不通知、不气泡;隐藏和 AI 忙只发系统通知;拖动中进入可见队列;20 天前历史可清理 | 系统通知是否真正弹出受 Windows/托盘后端影响,Qt 只能确认发起状态 | v2 行为保持,复杂重复规则、跨平台通知、自定义稍后间隔延期 |
| 角色 | 设置页角色页和 `CharacterPackageRepository` | 导入先验证再复制;切换保存后立即生效;用户角色可删除;任意角色可导出;可打开用户角色目录 | 内置角色不可删除/覆盖;导出目标存在需二次确认;损坏角色包不复制 | 目标目录权限失败、用户取消、角色包资源缺失 | 当前角色管理补全完成,角色市场/分享延期 |
| 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 |
| 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 |
| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 |
| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;静态规则验证发布输入、内部 QA 隔离、安装器自启动与卸载清理声明 | 产品 Release、`windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 内部 QA 已独立验证;产品 Release 与安装器等待用户构建输入 |
+34
View File
@@ -0,0 +1,34 @@
# QtDesktopPet 文档索引
本文档用于区分当前有效文档和历史归档文档,避免后续开发继续引用过时路线。
## 当前有效文档
| 文档 | 用途 |
| --- | --- |
| `QtDesktopPet_后续功能规划与结构审查.md` | 当前功能状态、下一阶段路线和结构边界 |
| `QtDesktopPet_测试清单与验收标准.md` | 功能测试清单、回归验收标准和静态推演记录 |
| `performance_stability_check.md` | 性能采样、稳定性检查和发布验证入口 |
| `test-records/` | 每次回归、性能和候选发布的长期测试记录 |
## 归档文档
历史文档统一放在 `docs/archive/`。这些文档保留为开发上下文,不再作为当前施工依据。
| 文档 | 归档原因 |
| --- | --- |
| `archive/implementation_plan.md` | 早期分阶段实施计划,当前大部分阶段已完成或被后续模块替代 |
| `archive/Qt_DesktopPet_开发文档.md` | 初版开发总纲,当前产品能力已超出第一版范围 |
| `archive/QtDesktopPet_后续功能规划与结构审查_旧版.md` | 旧后续规划过长,包含大量已完成阶段和旧文件操作路线 |
| `archive/DesktopPet 本地工作区 Agent 开发文档_草案.md` | 已终止的本地工作区 Agent 初稿 |
| `archive/DesktopPet_本地工作区Agent_施工计划_已终止.md` | 工作区 Agent 路线已从当前产品移除 |
| `archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md` | 独立 Agent 窗口路线已从当前产品移除 |
## 维护规则
- 新功能进入开发前,先更新当前路线文档和对应测试清单。
- 功能完成后,同步更新 README、测试清单和性能检查项。
- 历史文档不再直接修改,除非补充归档说明。
- GitHub 公开导出不包含 `docs/`;开发仓库保留完整文档。
- 内部自动化测试位于 `internal/qa/`,只进入开发仓库,不进入 GitHub 公开导出和发布包。
- 在构建卡住问题解决前,文档中的验证步骤仍以用户手动构建为准,不由 Codex 调用 CMake 或构建命令。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,871 @@
# DesktopPet 本地工作区 Agent 施工计划
> 归档说明:该路线已于 2026-07-16 从 QtDesktopPet 当前产品方向中移除,本文仅保留历史设计,不再作为施工依据。
## 1. 定位
本地工作区 Agent 是下一阶段核心功能。它不是继续维护旧版“文件操作 v1”,而是新增一个独立的工作区系统:
> 用户选择一个本地工作区目录后,桌宠可以读取、搜索、分析该目录内的文件,并在用户确认后创建、修改、复制、移动、重命名或删除到回收站。
第一阶段优先实现文件级工具,不做 Shell 命令执行,不做脚本执行,不做自动安装依赖。
## 2. 总原则
- 不允许 AI 直接获得全盘文件权限。
- 不允许 AI 自由执行系统命令。
- 不允许永久删除文件。
- 不允许绕过用户确认修改本地文件。
- 不允许默认读取敏感文件。
- 所有写操作必须先生成计划,展示路径、动作和风险,用户确认后才能执行。
- 所有文件路径必须限制在用户选择的工作区内。
- `PetWindow` 只负责 UI 入口、展示和确认,不承载复杂业务逻辑。
- 新模块先新增、后替换旧 `src/fileops/`。
## 3. 分期目标
当前执行状态:
- v1a.1/v1b/v1b.5 已落地到 `src/workspace/` 和 `src/ui/WorkspaceAgentWindow.*`。
- 当前不持久化工作区路径,不执行写操作,不替换旧 `src/fileops/`。
- v1b 已支持打开工作区内普通文件、打开文件夹、显示文件所在位置。
- v1b.5 已提供独立 Agent 工作区窗口、右键/托盘入口和默认 `Alt+A` 呼出快捷键;窗口包含左侧结果导航、中间任务对话、右侧详情/上下文和 v1c 预留变更区。
### v1a:只读工作区
目标:
- 选择工作区。
- 显示当前工作区。
- 扫描工作区文件树。
- 搜索文件名。
- 搜索文本内容。
- 读取文本文件。
- 分析项目结构。
- 总结文件内容。
验收:
- 工作区外路径被拒绝。
- 系统目录被拒绝。
- `.git/build/node_modules/dist/release_packages` 等目录被跳过。
- 敏感文件默认拒绝读取。
- 二进制文件拒绝读取。
### v1b:打开和定位
状态:已落地。
目标:
- 打开工作区内普通文件。
- 打开工作区内文件夹。
- 显示文件所在位置。
验收:
- 危险后缀拒绝打开。
- `.lnk` 不直接打开。
- 工作区外路径拒绝。
- 打开文件必须二次确认,打开文件夹作为低风险操作直接执行。
### v1c:低风险写入
进入 v1c 前已完成:
- `创建 test.txt / 新建文件 / 写入 README / 修改 main.cpp` 明确拒绝,不再误进入旧 fileops 读取文件流程。
- 独立 Agent 窗口可以分区展示上一轮结果、文本命中、上下文、选中项详情和最近读取文件。
- 窗口支持对选中结果执行读取、打开和显示位置。
- 窗口可通过桌宠右键菜单、托盘菜单和可配置全局快捷键呼出。
目标:
- 创建文本文件。
- 创建文件夹。
- 复制单个文件。
- 同目录重命名文件。
验收:
- 所有写操作必须确认。
- 目标文件或目录已存在时拒绝覆盖。
- 文件名非法时拒绝。
- 危险后缀拒绝创建或写入。
### v1d:高风险写入
目标:
- 修改已有文本文件。
- 移动单个文件。
- 删除文件到回收站。
- 删除文件夹到回收站。
验收:
- 修改前必须备份。
- 移动和删除必须强确认。
- 删除只进入回收站。
- 批量删除第一阶段拒绝。
- 失败时不能破坏原文件。
### v1e:替换旧文件操作
目标:
- `FileOperation` 意图迁移为 `Workspace`。
- `PetWindow::handleFileOperationChatMessage()` 过渡为调用 `WorkspaceController`。
- 新模块覆盖旧能力后删除旧 `src/fileops/`。
验收:
- 无 `FileOperationManager` 有效引用。
- CMake 不再登记旧 fileops 文件。
- README 和测试文档改为“本地工作区 Agent”。
## 4. 新目录结构
建议新增:
```text
src/workspace/
├── WorkspaceTypes.h
├── WorkspaceSession.h
├── WorkspaceSession.cpp
├── WorkspaceIndex.h
├── WorkspaceIndex.cpp
├── WorkspaceScanner.h
├── WorkspaceScanner.cpp
├── WorkspaceAccessPolicy.h
├── WorkspaceAccessPolicy.cpp
├── WorkspaceTool.h
├── WorkspaceToolRegistry.h
├── WorkspaceToolRegistry.cpp
├── WorkspaceAgent.h
├── WorkspaceAgent.cpp
├── WorkspaceController.h
├── WorkspaceController.cpp
├── WorkspaceHistory.h
├── WorkspaceHistory.cpp
└── tools/
├── ListFilesTool.h
├── ListFilesTool.cpp
├── ReadFileTool.h
├── ReadFileTool.cpp
├── SearchFilesTool.h
├── SearchFilesTool.cpp
├── SearchTextTool.h
├── SearchTextTool.cpp
├── OpenFileTool.h
├── OpenFileTool.cpp
├── CreateFileTool.h
├── CreateFileTool.cpp
├── CreateDirectoryTool.h
├── CreateDirectoryTool.cpp
├── WriteFileTool.h
├── WriteFileTool.cpp
├── CopyFileTool.h
├── CopyFileTool.cpp
├── MoveFileTool.h
├── MoveFileTool.cpp
├── RenameFileTool.h
├── RenameFileTool.cpp
├── DeleteToTrashTool.h
└── DeleteToTrashTool.cpp
```
第一阶段继续保持 `CMAKE_AUTOMOC OFF`,新增业务类不使用 `Q_OBJECT`。
## 5. 核心类型
```cpp
enum class WorkspacePermissionMode
{
ReadOnly,
ConfirmBeforeWrite,
Advanced
};
```
第一阶段默认 `ConfirmBeforeWrite`,但所有写操作仍必须确认。`Advanced` 只预留,不启用自动写入。
```cpp
enum class WorkspaceToolRisk
{
ReadOnly,
Low,
Medium,
High,
Dangerous
};
```
风险定义:
- `ReadOnly`:列目录、搜索、读取普通文本文件。
- `Low`:打开文件夹、显示文件所在位置。
- `Medium`:打开普通文件、创建文件、创建文件夹、复制文件。
- `High`:修改文件、移动文件、重命名文件、删除到回收站。
- `Dangerous`:永久删除、执行命令、运行脚本、修改系统目录。
第一阶段遇到 `Dangerous` 直接拒绝。
```cpp
struct WorkspaceFileInfo
{
QString relativePath;
QString absolutePath;
QString fileName;
QString suffix;
qint64 sizeBytes = 0;
QDateTime lastModified;
bool isDirectory = false;
bool isTextFile = false;
bool isHidden = false;
};
```
```cpp
enum class WorkspaceActionType
{
Unknown,
ListFiles,
SearchFiles,
SearchText,
ReadFile,
OpenFile,
OpenDirectory,
RevealInExplorer,
CreateFile,
CreateDirectory,
WriteFile,
CopyFile,
MoveFile,
RenameFile,
DeleteToTrash
};
```
```cpp
struct WorkspaceActionPlan
{
QString id;
WorkspaceActionType type = WorkspaceActionType::Unknown;
WorkspaceToolRisk risk = WorkspaceToolRisk::ReadOnly;
QString title;
QString description;
QString sourcePath;
QString targetPath;
QString relativeSourcePath;
QString relativeTargetPath;
QString content;
QString searchKeyword;
QStringList warnings;
bool requiresConfirmation = false;
bool allowOverwrite = false;
bool confirmed = false;
};
```
```cpp
struct WorkspaceToolResult
{
bool success = false;
QString message;
QString errorMessage;
QString outputText;
QString targetPath;
QVector<WorkspaceFileInfo> files;
};
```
## 6. 安全策略
`WorkspaceAccessPolicy` 是最重要的模块,所有工具必须经过它校验。
职责:
- 路径标准化。
- 判断路径是否在工作区内。
- 阻止 `../` 跳出工作区。
- 阻止符号链接跳出工作区。
- 阻止系统目录。
- 阻止危险文件类型。
- 阻止敏感文件默认读取。
- 限制写入行为。
- 限制删除行为。
建议接口:
```cpp
class WorkspaceAccessPolicy
{
public:
explicit WorkspaceAccessPolicy(const QString &workspaceRoot);
QString workspaceRoot() const;
QString normalizePath(const QString &path) const;
QString toAbsolutePath(const QString &relativeOrAbsolutePath) const;
QString toRelativePath(const QString &absolutePath) const;
bool isInsideWorkspace(const QString &path, QString *errorMessage = nullptr) const;
bool hasSymlinkSegment(const QString &path, QString *errorMessage = nullptr) const;
bool isSystemPath(const QString &path) const;
bool isDangerousSuffix(const QString &path) const;
bool isSensitiveFile(const QString &path) const;
bool isSupportedTextFile(const QString &path) const;
bool canReadFile(const QString &path, QString *errorMessage = nullptr) const;
bool canReadDirectory(const QString &path, QString *errorMessage = nullptr) const;
bool canCreateFile(const QString &path, QString *errorMessage = nullptr) const;
bool canCreateDirectory(const QString &path, QString *errorMessage = nullptr) const;
bool canWriteFile(const QString &path, QString *errorMessage = nullptr) const;
bool canMoveFile(const QString &sourcePath, const QString &targetPath, QString *errorMessage = nullptr) const;
bool canDeleteToTrash(const QString &path, QString *errorMessage = nullptr) const;
};
```
禁止危险后缀:
```text
.exe .bat .cmd .ps1 .vbs .msi .scr .reg .com .pif .lnk
```
敏感文件默认拒绝:
```text
.env
*.pem
*.key
id_rsa
id_ed25519
*.pfx
*.p12
credentials.json
secrets.json
token.json
```
文件名包含以下关键词也视为敏感:
```text
secret
token
password
credential
private
apikey
api_key
```
## 7. 扫描和索引
`WorkspaceScanner` 第一版使用 `QDir` + 手动目录栈扫描工作区,后续如需取消、进度回调或更细粒度过滤,再考虑替换为 `QDirIterator` 或异步扫描。
默认限制:
- 最大递归深度:8。
- 最大文件数量:5000。
- 跳过隐藏目录。
- 跳过常见产物目录。
跳过目录:
```text
.git
.vs
.idea
build
cmake-build-debug
cmake-build-release
node_modules
dist
release
release_packages
logs
```
`WorkspaceIndex` 第一版只做内存索引:
```cpp
class WorkspaceIndex
{
public:
void clear();
void setFiles(const QVector<WorkspaceFileInfo> &files);
QVector<WorkspaceFileInfo> allFiles() const;
QVector<WorkspaceFileInfo> findByName(const QString &keyword, int maxResults) const;
QVector<WorkspaceFileInfo> findBySuffix(const QStringList &suffixes, int maxResults) const;
QVector<WorkspaceFileInfo> recentFiles(int maxResults) const;
};
```
## 8. 工具清单
### 8.1 只读工具
`ListFilesTool`
- 列出工作区内目录。
- 最多显示 200 项。
- 不递归或浅递归。
`ReadFileTool`
- 只读文本文件。
- 最大读取 128KB。
- 超出截断。
- 敏感文件默认拒绝。
- 不读二进制文件。
支持后缀:
```text
.cpp .h .hpp .c .cc .py .js .ts .json .md .txt .ini .xml .yaml .yml .cmake .pro .css .html
```
`SearchFilesTool`
- 只搜索当前工作区索引。
- 最多返回 30 个结果。
- 按相关性和最近修改时间排序。
`SearchTextTool`
- 最多扫描 1000 个文本文件。
- 单文件最大读取 128KB。
- 最多返回 50 条匹配。
- 每条显示文件路径、行号、片段。
### 8.2 打开工具
`OpenFileTool`
- 用系统默认程序打开文件。
- 只能打开工作区内普通文件。
- 危险后缀拒绝。
- 敏感文件需要确认或第一版拒绝。
`OpenDirectoryTool`
- 打开工作区内目录。
- 工作区外路径拒绝。
`RevealInExplorerTool`
- 第一版可以先打开所在文件夹。
- 如果后续使用 `explorer.exe /select`,必须固定程序路径和参数,不接受聊天文本命令。
### 8.3 写入工具
`CreateFileTool`
- 只能在工作区内创建。
- 目标不能已存在。
- 文件名必须合法。
- 危险后缀拒绝。
- 必须确认。
`CreateDirectoryTool`
- 只能在工作区内创建。
- 目标不能已存在。
- 必须确认。
`WriteFileTool`
- 修改已有文本文件前自动备份。
- 禁止覆盖二进制文件。
- 禁止写敏感文件。
- 禁止写危险后缀。
- 必须确认。
备份目录:
```text
<workspaceRoot>/.desktoppet/backups/
```
备份命名:
```text
relative_path.replace("/", "__") + "." + yyyyMMdd-HHmmss + ".bak"
```
`CopyFileTool`
- 只复制单文件。
- 源和目标都必须在工作区内。
- 不覆盖。
- 必须确认。
`MoveFileTool`
- 只移动单文件。
- 源和目标都必须在工作区内。
- 不覆盖。
- 必须确认。
`RenameFileTool`
- 只允许同目录重命名。
- 文件名不能包含路径分隔符。
- 不覆盖。
- 必须确认。
`DeleteToTrashTool`
- 只移动到系统回收站。
- 不永久删除。
- 单文件删除第一阶段可支持。
- 文件夹删除需要强确认。
- 批量删除第一阶段拒绝。
## 9. WorkspaceAgent
第一版采用半自动 Agent,不做复杂工具循环。
推荐流程:
```text
用户输入
↓
WorkspaceController 判断工作区任务
↓
程序根据关键词和索引检索相关文件
↓
读取少量相关文件
↓
把文件树摘要、相关文件内容、用户问题交给模型
↓
模型返回分析结果或修改建议
↓
涉及写操作时生成 WorkspaceActionPlan
↓
用户确认
↓
执行工具
```
工作区专用系统提示词:
```text
你是 QtDesktopPet 的本地工作区助手。
你只能基于用户授权的工作区文件进行分析。
你不能假设自己看过未读取的文件。
你不能要求访问工作区外路径。
你不能执行系统命令。
你不能生成危险操作指令。
涉及创建、修改、移动、重命名、删除文件时,必须先生成清晰的操作计划,等待用户确认。
涉及敏感文件、密钥、凭证时,必须提醒风险。
回答代码问题时要引用具体文件路径。
当信息不足时,说明还需要读取哪些文件。
```
后续可升级为工具循环,但第一版不做。
## 10. WorkspaceController
建议接口:
```cpp
class WorkspaceController
{
public:
explicit WorkspaceController();
bool hasWorkspace() const;
QString workspaceRoot() const;
bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr);
void closeWorkspace();
WorkspaceToolResult scanWorkspace();
WorkspaceToolResult handleMessage(const QString &message);
bool hasPendingPlan() const;
WorkspaceActionPlan pendingPlan() const;
WorkspaceToolResult confirmPendingPlan();
void cancelPendingPlan();
private:
WorkspaceSession m_session;
WorkspaceAgent m_agent;
std::optional<WorkspaceActionPlan> m_pendingPlan;
};
```
原则:
- `handleMessage()` 不直接执行高风险写操作。
- 需要确认时保存到 `m_pendingPlan`。
- UI 调用 `confirmPendingPlan()` 后才执行。
- 所有错误通过 `WorkspaceToolResult` 返回,不在工具层弹窗。
## 11. UI 接入
聊天入口支持:
```text
打开工作区
选择工作区
当前工作区
扫描工作区
分析这个项目
搜索当前工作区
读取 main.cpp
打开第一个
```
如果没有选择工作区,提示用户选择目录。
气泡只显示短状态:
```text
已打开工作区。
正在扫描文件。
找到了 12 个相关文件。
需要你确认修改计划。
操作已完成。
```
复杂结果不全部塞进气泡,当前通过独立 Agent 窗口展示。
当前独立 Agent 窗口可显示:
- 当前工作区路径。
- 文件树。
- 搜索结果。
- 已读取文件。
- AI 分析结果。
- 待确认操作计划。
- 执行结果。
- 最近操作历史。
## 12. 意图分发
后续建议新增:
```cpp
enum class UserIntentType
{
Chat,
Reminder,
Weather,
Workspace,
FileOperation,
LaunchApp
};
```
意图原则:
- `Reminder` 保持最高优先级。
- 明确天气问答走 `Weather`。
- 明确工作区、代码、项目分析走 `Workspace`。
- 当前阶段,未明确“工作区/项目/代码”且没有工作区文件名特征的普通文件读写请求仍可走旧 `FileOperation`。
- 明确应用名启动走 `LaunchApp`。
- 其余走 `Chat` 或输入框联网模式。
不能用宽关键词简单吞掉所有请求:
- “打开微信”应走 `LaunchApp`。
- “打开文件夹”这种泛请求不走 `Workspace`;“打开 docs / 打开这个项目里的 docs”走 `Workspace`。
- “搜索 Qt 最新版本”不应走工作区搜索。
- “搜索这个项目里的 main”应走工作区搜索。
## 13. 迁移步骤
1. 新增 `src/workspace/`,不改旧 `src/fileops/`。
2. CMake 登记 workspace 新文件。
3. `PetWindow` 增加 `WorkspaceController` 成员。
4. 新增工作区打开、扫描、搜索、读取入口。
5. v1b 支持打开和定位;写操作计划和确认框从后续阶段开始。
6. 新模块覆盖旧文件操作能力后,`handleFileOperationChatMessage()` 转发到 `WorkspaceController`。
7. 确认无旧引用后删除 `src/fileops/`。
8. 更新 CMake、README、测试文档。
删除旧文件前必须执行:
```text
rg "FileOperationManager|FileSandbox|FileBackupManager|src/fileops|FileOperation"
```
确认没有有效代码引用后再删。
## 14. 日志要求
允许记录:
- 打开工作区路径。
- 扫描文件数量。
- 执行的工具名称。
- 操作是否成功。
- 错误摘要。
禁止记录:
- API Key。
- Authorization Header。
- 完整敏感文件内容。
- `.env` 内容。
- 密钥文件内容。
- 用户完整隐私文本。
使用项目已有 `Logger`,不要新增零散调试输出。
## 15. 测试清单
### 工作区选择
- 选择普通项目目录。
- 选择空目录。
- 选择不存在目录。
- 选择 `C:/Windows`,应拒绝。
- 选择 `Program Files`,应拒绝。
- 选择中文路径。
- 选择包含空格的路径。
### 扫描
- 扫描普通 Qt 项目。
- 扫描包含 build 目录的项目。
- 扫描包含 `.git` 的项目。
- 扫描大量文件目录。
- 扫描中文文件名。
- 扫描深层目录。
要求:
- 不崩溃。
- 不扫系统目录。
- 不进入跳过目录。
- 结果数量受限制。
### 搜索文件
- 搜索 `main`。
- 搜索 `.cpp`。
- 搜索 `README`。
- 搜索不存在文件。
- 搜索中文文件名。
要求:
- 最多返回 30 条。
- 显示相对路径。
- 结果可用于后续“打开第几个”。
### 搜索文本
- 搜索 `class`。
- 搜索 `include`。
- 搜索中文关键词。
- 搜索不存在关键词。
- 搜索超大文件。
要求:
- 显示路径、行号、片段。
- 跳过二进制文件。
- 跳过超大文件。
- 不读取敏感文件。
### 读取文件
- 读取 `.cpp`。
- 读取 `.md`。
- 读取 `.json`。
- 读取图片应拒绝。
- 读取 `.exe` 应拒绝。
- 读取 `.env` 应拒绝或强确认。
- 读取工作区外文件应拒绝。
### 创建文件
- 在工作区创建 `test.txt`。
- 创建已存在文件应拒绝。
- 创建非法文件名应拒绝。
- 创建 `.bat` 应拒绝。
- 创建到工作区外应拒绝。
### 修改文件
- 修改普通 `.txt`。
- 修改 `.cpp`。
- 修改前生成备份。
- 修改二进制文件应拒绝。
- 修改敏感文件应拒绝。
要求:
- 必须确认。
- 必须备份。
- 失败不能破坏原文件。
### 移动和重命名
- 移动普通文件。
- 移动到已存在目标应拒绝。
- 重命名普通文件。
- 重命名为非法文件名应拒绝。
- 移动到工作区外应拒绝。
### 删除到回收站
- 删除普通文件到回收站。
- 删除文件夹到回收站。
- 删除工作区外文件应拒绝。
- 永久删除请求应拒绝。
- 批量删除请求第一阶段应拒绝。
### 对话上下文
```text
用户:搜索 README
用户:打开第一个
用户:搜索 fileops
用户:读取第二个
用户:找一下包含 FileOperationManager 的文件
用户:总结这些文件的作用
```
要求:
- 系统能记住上一轮搜索结果。
- “第一个”“第二个”能正确解析。
- 上下文过期后要求用户重新选择。
## 16. 静态推演要求
每完成一个阶段,需要记录:
- 入口路径。
- 业务模块调用链。
- 路径校验点。
- 用户确认点。
- 失败回滚策略。
- 不支持能力的拒绝话术。
- 对提醒、天气、联网模式、应用启动的回归影响。
## 17. 最终验收标准
本轮重构完成后至少达到:
1. 用户可以选择一个本地项目目录作为工作区。
2. 程序可以扫描工作区并建立文件索引。
3. 用户可以通过对话搜索文件。
4. 用户可以通过对话读取和分析文本文件。
5. 用户可以要求分析项目结构。
6. 用户可以创建文本文件和文件夹。
7. 用户可以移动、重命名、删除到回收站。
8. 所有写操作必须展示计划并等待确认。
9. 所有路径必须限制在工作区内。
10. 不允许永久删除。
11. 不允许执行脚本或命令。
12. 不允许默认读取敏感文件。
13. 旧文件操作功能被新 Workspace 系统替代。
14. 项目可以由用户手动构建通过。
File diff suppressed because it is too large Load Diff
@@ -1408,7 +1408,7 @@ Windows 下不能只拷贝 exe。
1. 用户手动完成 Release 构建 1. 用户手动完成 Release 构建
2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径 2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径
3. 脚本调用 windeployqt 收集 Qt 运行库 3. 脚本调用 windeployqt 收集 Qt 运行库
4. 脚本复制 resources/characters、resources/icons、LICENSE、README.md 4. 脚本复制 resources/characters、resources/icons、resources/sounds、LICENSE、README.md
5. 脚本生成 dist/QtDesktopPet-<version>-windows-x64.zip 5. 脚本生成 dist/QtDesktopPet-<version>-windows-x64.zip
6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss 6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss
7. 安装器默认最终输出到项目根目录 7. 安装器默认最终输出到项目根目录
@@ -1696,8 +1696,9 @@ MIT License 开源
当前仍需补齐: 当前仍需补齐:
```text ```text
1. 角色包导出和更完整管理界面 1. 发布前素材授权确认与打包验证
2. 对话历史导出、搜索或更完整管理界面 2. 长期性能压测记录
3. 发布前素材授权确认与打包验证 3. 本地文件操作 zip 打包能力,如后续确认压缩库方案再补
4. 长期性能压测记录 4. 联网模式后续可补更多 AI Provider 原生联网适配、结构化搜索 API 或自建联网后端;网页全文抓取和长期缓存仍需先确认安全边界
5. 应用启动跨平台发现、脚本/命令执行和管理员权限当前不支持,后续如确需增加必须重新评估安全边界
``` ```
@@ -597,8 +597,8 @@ release_packages/
当前实现与计划仍存在差异: 当前实现与计划仍存在差异:
```text ```text
1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程 1. SettingsDialog 已支持角色导入、切换、删除用户角色、导出角色和打开用户角色目录;后续更复杂的角色市场/分享仍不在当前范围
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面 2. 对话历史已有内存上限、可选本地保存、关键词搜索、Provider/模型筛选和 Markdown/JSON 导出
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录 3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护 4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
``` ```
@@ -636,12 +636,12 @@ release_packages/
```text ```text
1. 完善设置界面: 1. 完善设置界面:
- 角色导出
- 打开用户角色目录
- 更完整的角色管理状态提示 - 更完整的角色管理状态提示
- 后续如果做角色分享,需要补版权提示和包格式校验
2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录 2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录
3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略 3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略
4. 做发布包安装/卸载实机验证,并确认 release_packages/ 或根目录安装包输出规则 4. 做发布包安装/卸载实机验证,并确认 release_packages/ 或根目录安装包输出规则
5. 按 docs/QtDesktopPet_测试清单与验收标准.md 做全功能手测
``` ```
--- ---
@@ -652,6 +652,6 @@ release_packages/
```text ```text
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中 1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
2. 角色管理下一步是否需要导出、打开用户角色目录 2. 角色管理后续是否需要角色分享/市场能力
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理 3. 对话历史后续是否需要更复杂的会话分组、归档或全文索引
``` ```
+25 -11
View File
@@ -22,13 +22,13 @@
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条: 默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条:
```powershell ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m
``` ```
指定 PID 采样: 指定 PID 采样:
```powershell ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 12345 -DurationSeconds 600 powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Pid 12345 -Scenario idle-10m -DurationSeconds 600
``` ```
输出目录默认是: 输出目录默认是:
@@ -37,22 +37,34 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 1
reports/perf/ reports/perf/
``` ```
`reports/perf/` 已加入 `.gitignore`。需要沉淀结论时,只把摘要写入本文档,不提交原始采样 CSV。 `reports/perf/` 已加入 `.gitignore`。原始 CSV 不提交;使用报告脚本生成统计摘要,审核并脱敏后写入对应的 `docs/test-records/` 记录:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/New-PerformanceReport.ps1 `
-InputPath reports/perf/<sample>.csv -Scenario idle-5m
```
默认只强制判定进程意外退出和无响应。CPU、内存、句柄和线程阈值保存在 `internal/qa/config/performance-thresholds.json`,取得稳定 Release 基线后再按场景填写。
下表中的 `perf_sample.ps1` 均指 `internal/qa/scripts/perf_sample.ps1`。
## 检查项 ## 检查项
| 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 | | 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| 启动后静置 | 启动程序后不操作 5 分钟 | `tools/perf_sample.ps1` 默认参数 | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO | | 启动后静置 | 启动程序后不操作 5 分钟 | `perf_sample.ps1 -Scenario idle-5m` | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `tools/perf_sample.ps1 -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO | | idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `perf_sample.ps1 -Scenario idle-10m -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
| 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO | | 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO |
| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `tools/perf_sample.ps1 -DurationSeconds 300` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO | | 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `perf_sample.ps1 -Scenario show-hide` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO |
| 重复启动 / 单实例 | 已运行时连续双击 exe 多次;隐藏到托盘后再双击 exe | 任务管理器或 `Get-Process QtDesktopPet` | 始终只有一个进程;隐藏状态可被重复启动唤醒 | TODO | TODO | 检查 QLocalServer 激活路径 | | 重复启动 / 单实例 | 已运行时连续双击 exe 多次;隐藏到托盘后再双击 exe | 任务管理器或 `Get-Process QtDesktopPet` | 始终只有一个进程;隐藏状态可被重复启动唤醒 | TODO | TODO | 检查 QLocalServer 激活路径 |
| 设置页居中 / 置前 | 从托盘打开设置页;多屏环境重复打开;设置页已打开时再次启动 exe | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 | | 设置页居中 / 置前 | 从托盘打开设置页;多屏环境重复打开;设置页已打开时再次启动 exe | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 |
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `tools/perf_sample.ps1 -DurationSeconds 300` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 | | 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `perf_sample.ps1 -Scenario state-switch` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 | | 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `perf_sample.ps1 -Scenario prewarm-unload -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO | | 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `perf_sample.ps1 -Scenario scale-topmost` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 | | AI 对话 | 连续发送 20 轮短消息 | `perf_sample.ps1 -Scenario ai-20-turns -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `perf_sample.ps1 -Scenario reminder-trigger` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 |
| 天气查询 | 连续查询当前/明天/未来三天天气 | `perf_sample.ps1 -Scenario weather-queries` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 |
| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `perf_sample.ps1 -Scenario web-queries` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 |
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 | | 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 | | 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
@@ -75,7 +87,7 @@ reports/perf/
```text ```text
1. 后台线程预热 1. 后台线程预热
2. 单帧级缓存 2. 单帧级缓存
3. 自动化长期压测记录 3. 经 Release 基线校准的自动性能阈值
``` ```
## 发布包说明 ## 发布包说明
@@ -85,6 +97,7 @@ reports/perf/
```text ```text
tools/ tools/
docs/ docs/
internal/
reports/ reports/
build/ build/
dist/ dist/
@@ -99,6 +112,7 @@ QtDesktopPet.exe
Qt 运行时依赖 Qt 运行时依赖
resources/characters/ resources/characters/
resources/icons/ resources/icons/
resources/sounds/
LICENSE LICENSE
README.md README.md
``` ```
@@ -0,0 +1,164 @@
# QtDesktopPet 预发布测试记录
## 构建信息
| 项目 | 内容 |
| --- | --- |
| 测试日期 | 2026-07-17 至 2026-07-18;2026-09-08 至 2026-09-09 复测 |
| 版本 / 发布阶段 | `0.2.0` / 发布候选验收完成 |
| 测试源码基线 | `90262aa` + 本记录所在提交;`90262aa` 已推送开发仓库 |
| 工作树说明 | 连接测试、提醒按钮定位、天气城市/IP 定位、诊断脱敏、QA 扩展、中文安装器和发布脚本已完成收口 |
| 构建类型 | Release |
| 编译器 / Qt | Qt 6.5.3 / MSVC 19.44 |
| 操作系统 | Windows,具体版本待补充 |
| 显示器 / DPI | 待补充 |
| 测试人 | 用户手动测试 |
## 自动化摘要
| 检查 | 结果 | 摘要 |
| --- | --- | --- |
| 核心逻辑 C++ 测试 | PASS | 2026-09-08 使用 Qt 6.5.3 / MSVC 19.44 构建并执行隔离 QA,43 项通过,0 项失败 |
| 静态检查 | PASS | 14 项通过,0 项失败;覆盖发布包、MSVC 运行库、中文安装器和隔离规则 |
| 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 |
| GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA |
## 已执行手测
| 用例 | 结果 | 实际结果 / 备注 |
| --- | --- | --- |
| 启动 | PASS | 用户确认通过 |
| 拖动 | PASS | 用户确认通过 |
| 置顶及重启保持 | PASS | 用户确认通过 |
| 托盘隐藏 / 显示 | PASS | 用户确认通过 |
| 单实例唤醒 | PASS | 用户确认通过 |
| 设置页居中 / 置前 | PASS | 用户确认通过 |
| 应用设置保存及重启保持 | PASS | 用户确认通过 |
| 开机自启动 | WAIVED | 用户于 2026-09-08 决定免测并接受当前实现,不作为技术验证 PASS |
| 导入合法角色包 | PASS | 用户于 2026-07-18 确认通过 |
| 切换用户角色并重启保持 | PASS | 用户于 2026-07-18 确认通过 |
| 同 ID 用户角色取消覆盖 | PASS | 用户于 2026-07-18 确认通过 |
| 拒绝缺少 `character.json` 的角色包 | PASS | 用户于 2026-07-18 确认通过 |
| 拒绝缺少 `idle` 状态的角色包 | PASS | 用户于 2026-07-18 确认通过 |
| 内置角色删除保护 | PASS | 用户于 2026-07-18 确认通过 |
| 导出角色及文件完整性 | PASS | 用户于 2026-07-18 确认通过 |
| 导出目标冲突确认与取消保护 | PASS | 用户于 2026-07-18 确认通过 |
| 打开用户角色目录 | PASS | 用户于 2026-07-18 确认通过 |
| 删除当前用户角色并回退默认角色 | PASS | 用户于 2026-07-18 确认通过 |
| AI 配置保存并重启保持 | PASS | 用户于 2026-07-18 确认通过 |
| 正确配置连接测试 | PASS | `AI-CONNECT-001` 修复后,用户于 2026-09-08 使用原配置复测通过 |
| 错误 Key / Base URL 错误提示 | PASS | 用户于 2026-07-18 确认通过 |
| 普通聊天 | PASS | 用户于 2026-07-18 确认通过 |
| 流式回复 | PASS | 用户于 2026-07-18 确认通过 |
| AI 忙时拒绝第二个请求 | PASS | 用户于 2026-07-18 确认通过 |
| 取消 AI 请求 | PASS | 用户于 2026-07-18 确认通过 |
| 本地历史保存和重启恢复 | PASS | 用户于 2026-07-18 确认通过 |
| 历史关键词搜索 | PASS | 用户于 2026-07-18 确认通过 |
| Provider / 模型筛选 | PASS | 用户于 2026-07-18 确认通过 |
| 导出 Markdown | PASS | 用户于 2026-07-18 确认通过 |
| 导出 JSON | PASS | 用户于 2026-07-18 确认通过 |
| 关闭历史保存后不持久化新消息 | PASS | 用户于 2026-07-18 确认通过 |
| 清空聊天历史及二次确认 | PASS | 用户于 2026-07-18 确认通过 |
| 一次性提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 中文相对时间 | PASS | 用户于 2026-09-08 确认通过 |
| 绝对日期 | PASS | 用户于 2026-09-08 确认通过 |
| 每天 / 每周 / 每月重复提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 复杂重复规则拒绝 | PASS | 用户于 2026-09-08 确认通过 |
| 提醒列表 | PASS | 用户于 2026-09-08 确认通过 |
| 聊天取消提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 设置页编辑 pending | PASS | 用户于 2026-09-08 确认通过 |
| 桌宠可见时触发 | PASS | 动态定位修复后,用户于 2026-09-08 复测通过 |
| 桌宠隐藏时触发 | PASS | 用户于 2026-09-08 确认通过 |
| AI 忙时触发 | PASS | 用户于 2026-09-08 确认通过 |
| 拖动中触发 | PASS | 用户于 2026-09-08 确认通过 |
| 多提醒同时触发 | PASS | 用户于 2026-09-08 确认通过 |
| 知道了 | PASS | 用户于 2026-09-08 确认通过 |
| 5分钟后再提醒 | PASS | 用户于 2026-09-08 确认通过 |
| 20天历史清理 | PASS | 用户于 2026-09-08 确认通过 |
| 自定义音效导入 / 试听 / 删除 | PASS | 用户于 2026-09-08 确认通过 |
| 内置音效保护 | PASS | 用户于 2026-09-08 确认通过 |
| 睡眠与系统时间变化 | PASS | 用户于 2026-09-08 确认通过 |
| 提醒保存失败回滚与重试 | PASS | 用户于 2026-09-08 确认通过 |
| 洛杉矶天气 | PASS | 用户于 2026-09-08 确认可识别并返回天气 |
| 华盛顿天气 | PASS | 用户于 2026-09-08 确认可识别并返回天气 |
| 西安天气候选 | PASS | 低价值同名候选过滤修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 纽约天气 | PASS | 中文海外城市别名修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 东京天气 | PASS | 中文别名与候选过滤修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 日本东京天气 | PASS | 国家限定解析修复后,用户于 2026-09-08 完成 T06 最小清单复测 |
| 公网 IP 定位 | PASS | `ipapi.co` 失败后切换 `ipwho.is` 的备用链路通过用户复测 |
| 文件操作已移除 | PASS | 用户于 2026-09-08 确认不再弹文件选择框或访问本地文件 |
| 应用启动已移除 | PASS | 用户于 2026-09-08 确认不再弹 exe 选择/确认框或启动程序 |
| 工作区 Agent 已移除 | PASS | 用户于 2026-09-08 确认不再扫描目录或打开 Agent 窗口 |
| 本地能力菜单入口清理 | PASS | 用户于 2026-09-08 确认右键与托盘入口已清理 |
| 本地能力设置页清理 | PASS | 用户于 2026-09-08 确认应用启动和 Agent 页面已清理 |
| Agent 快捷键清理 | PASS | 用户于 2026-09-08 确认 `Alt+A` 不再呼出 Agent |
| 旧本地能力配置兼容 | PASS | 用户于 2026-09-08 确认保留旧配置启动不崩溃且不恢复旧能力 |
| `0.2.0` Release 构建 | PASS | 用户于 2026-09-08 使用 Qt 6.5.3 / MSVC Release Kit 完成构建 |
| 中文安装器界面 | PASS | 用户于 2026-09-09 确认安装器实机验收通过 |
| 桌面快捷方式和开机自启默认未选 | PASS | 用户于 2026-09-09 确认默认选项符合预期 |
| 安装完成并启动 | PASS | 用户于 2026-09-09 确认通过 |
| 覆盖安装 | PASS | 用户于 2026-09-09 确认通过 |
| 卸载时保留用户数据 | PASS | 用户于 2026-09-09 确认通过 |
| 卸载时删除用户数据 | PASS | 用户于 2026-09-09 确认通过 |
## T09 自动化验证
| 用例 | 结果 | 实际结果 / 备注 |
| --- | --- | --- |
| App 配置损坏 | PASS | 损坏文件被备份,加载默认 AppConfig |
| AI 配置损坏 | PASS | 损坏文件被备份,回退未配置的 Custom Provider |
| 天气配置损坏 | PASS | 损坏文件被备份,回退 Open-Meteo 默认配置 |
| 联网配置损坏 | PASS | 损坏文件被备份,回退默认联网开关配置 |
| 提醒数据损坏 | PASS | 损坏文件被备份,返回空提醒列表 |
| 历史数据损坏 | PASS | 既有 ConversationStore 自动化验证备份并忽略损坏历史 |
| 日志隐私 | PASS | 诊断上下文、JSON 摘要和错误摘要均不泄露 Key、Bearer、token、message/content 内容 |
| 同秒重复损坏 | PASS | 连续损坏生成带后缀的独立备份,不覆盖前一份 |
## T10 发布与安装验收
| 检查 | 结果 | 实际结果 / 备注 |
| --- | --- | --- |
| PowerShell / JSON 语法 | PASS | QA、性能、发布和 GitHub 导出脚本均可解析 |
| 发布包输入 | PASS | 包含 exe、Qt runtime、应用本地 MSVC x64 runtime、characters/icons/sounds、README、LICENSE |
| 发布隔离 | PASS | 发布脚本不复制 `docs/internal/reports`,产品 CMake/打包脚本不依赖内部 QA |
| 安装器结构 | PASS | 简体中文、per-user 安装、x64、文件递归、自启动默认未选、Run 项和卸载清理逻辑均已验证 |
| MSVC 依赖闭合 | PASS | `MSVCP140/140_1/140_2` 与 `VCRUNTIME140/140_1` 均随包部署,依赖扫描无缺失 |
| ZIP 发布包 | PASS | `QtDesktopPet-0.2.0-windows-x64.zip`,SHA256 `A247A6AB7EC467B2FC1EA2439611660E9E269E573860EDD587E7D65243768661` |
| 中文安装器 | PASS | `QtDesktopPet-0.2.0-windows-x64-setup.exe`,SHA256 `0FE7E215C649E4C2175736C8798BF25F1FDEB34C1B45632CD8824F3F6DDEE709` |
| Release / 实际打包 / 安装卸载 | PASS | 用户完成 Release 构建,并于 2026-09-09 确认中文安装、覆盖安装、启动和卸载数据选项通过 |
| 数字签名 | ACCEPTED_LIMITATION | 当前安装器未签名,Windows SmartScreen 可能显示“未知发布者”;不作为 `0.2.0` 预发布阻断项 |
## 批次状态
| 批次 | 范围 | 结果 | 备注 |
| --- | --- | --- | --- |
| T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 |
| T02 | 应用设置与开机自启动 | ACCEPTED | 应用设置持久化通过;开机自启动由用户决定免测并接受 |
| T03 | 角色管理 | PASS | 10 项全部通过 |
| T04 | AI 与聊天历史 | PASS | 连接测试修复后复测通过;本批次全部通过 |
| T05 | 定时提醒 | PASS | 动态按钮定位复测通过,提醒批次关闭 |
| T06 | 天气查询 | PASS | 海外中文城市、候选过滤、国家限定和 IP 备用服务均通过最小清单复测 |
| T07 | 联网模式 | BLOCKED | 当前没有可用的 OpenAI/Gemini 官方 Key;不是功能失败 |
| T08 | 已移除能力与安全边界 | PASS | 用户确认全部 7 项通过 |
| T09 | 配置损坏与隐私 | PASS | 8 项自动化验证通过,测试仅使用 Qt 测试专用配置目录 |
| T10 | 打包、安装与卸载 | PASS | `0.2.0` 中文安装器、运行库闭合、安装启动、覆盖安装及卸载均通过 |
## 性能状态
实际 QtDesktopPet 性能场景尚未执行。QA 脚本自检结果不得作为产品性能结论。
## 缺陷与复测
| 缺陷 ID | 首次现象 | 严重级别 | 修复状态 | 复测结果 |
| --- | --- | --- | --- | --- |
| AI-CONNECT-001 | 正常聊天可用,但连接测试返回 `HTTP 200: AI response content is empty.` | Medium | 已移除测试请求的 16 token 限制,并补充 token 截断提示 | PASS / CLOSED(2026-09-08) |
| REMINDER-UI-001 | 到点提醒的“知道了”和“5分钟后再提醒”按钮距角色主体偏远 | Low | 已改为按当前角色帧 Alpha 可见边界和可见尺寸动态定位,并保留屏幕边缘回退 | PASS / CLOSED(2026-09-08) |
| WEATHER-GEO-001 | 西安出现低价值候选,纽约/东京中文名无法稳定解析,国家限定失败 | Medium | 增加集中式中文城市别名、国家限定、人口/地点等级候选过滤,并修复中文被误判为两位国家码 | PASS / CLOSED(2026-09-08) |
| WEATHER-IP-001 | ipapi.co 服务端错误或超时导致公网 IP 定位失败 | Medium | 改为 ipapi.co 失败或 5 秒超时后自动尝试 ipwho.is,全部失败才提示配置默认城市 | PASS / CLOSED(2026-09-08) |
## 当前结论
- 已执行手测:72 项 `PASS`,0 项 `FAIL`,1 项 `WAIVED`。
- 已执行自动化:核心逻辑 C++ 测试 43 项 `PASS`,静态检查 14 项 `PASS`。
- T07 因缺少官方联网 Key 处于 `BLOCKED`,不是已发现的功能失败;产品性能场景尚未执行。
- `0.2.0` 可作为预发布版本发布;已知限制为联网模式未实测、安装器未数字签名,公开分发时应明确说明。
@@ -0,0 +1,48 @@
# QtDesktopPet QA 基础设施验证记录
## 构建信息
| 项目 | 内容 |
| --- | --- |
| 测试日期 | 2026-07-17 |
| 验证范围 | 内部 QA 基础设施,不是产品发布验收 |
| Git commit | `d0d1de4` |
| 工作树状态 | dirty;包含产品能力收缩、无用代码清理和本次 QA 基础设施改动 |
| 构建类型 | 未构建 |
| 操作系统 | Windows / Asia-Shanghai 环境 |
## 执行结果
| 检查 | 结果 | 摘要 |
| --- | --- | --- |
| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;本轮未调用 CMake、编译器或构建命令 |
| 静态检查 | PASS | 13 项通过,0 项失败 |
| PowerShell / JSON 解析 | PASS | 内部 QA 脚本和配置均可解析 |
| 性能报告器 fixture | PASS | 脱敏稳定样本成功生成 Markdown 报告,未出现退出或无响应判定 |
| 性能采样与报告链路 | PASS | 对临时 PowerShell 进程执行 1 秒采样并成功生成 PASS 报告;不代表产品性能 |
| 发布隔离规则 | PASS | 静态确认 GitHub 导出排除 `docs/internal/reports`,发布 CMake 和打包脚本不依赖内部 QA |
## 静态检查明细
- 92 个生产 `.h/.cpp` 均登记在根 CMake。
- 根 CMake 不引用缺失生产源码。
- 6 个内部测试翻译单元均登记在内部 QA CMake。
- 活跃生产代码中没有已移除模块路径、类型或意图动作。
- 生产代码没有 `Q_OBJECT`,保持 `CMAKE_AUTOMOC OFF`。
- 9 个发布必需输入存在。
- 默认角色清单可解析,所有声明状态均有 PNG 帧。
- GitHub 导出规则包含 `docs/internal/reports` 排除项。
- 生产构建和打包不依赖 `internal/qa`。
- `git diff --check` 通过,仅显示 Git 的 LF/CRLF 提示。
## 限制与后续
- 没有验证 C++ 测试能否编译和运行;需要用户使用匹配 Qt Kit 手动构建内部 QA 工程。
- 没有运行桌宠 Release,因此没有功能、UI、网络、安装或真实性能结论。
- 性能 fixture 只验证报告脚本算法和输出路径,不代表 QtDesktopPet 实际性能。
- 下一份记录应基于重新构建后的 Release,并从 `TEMPLATE.md` 创建正式验收记录。
## 最终结论
- QA 基础设施:`PASS WITH LIMITATIONS`
- 产品发布状态:`NOT EVALUATED`
+27
View File
@@ -0,0 +1,27 @@
# 测试记录维护规则
本目录保存 QtDesktopPet 每次正式回归、候选发布和性能验收的长期记录。验收标准定义在
`../QtDesktopPet_测试清单与验收标准.md`,本目录只记录某一次构建的实际执行结果。
## 文件规则
- 每次验收创建独立文件:`YYYY-MM-DD_<version>_<commit>.md`。
- 已完成记录不覆盖;复测结果追加在原记录的“缺陷与复测”部分。
- `index.md` 维护所有测试轮次和最终结论。
- 工作树不干净时,commit 后增加 `-dirty`,并记录差异摘要。
- 状态统一使用 `PASS / FAIL / BLOCKED / NOT_RUN`。
## 数据边界
- 允许提交:测试环境、用例结论、脱敏错误摘要、性能统计摘要和缺陷编号。
- 禁止提交:API Key、Authorization、完整用户对话、完整服务端错误正文、真实用户路径和原始性能 CSV。
- 原始性能 CSV 保存在 `reports/perf/`,该目录不进入 Git。
- 自动化临时输出保存在 `internal/qa/out/`,该目录不进入 Git。
## 工作流
1. 从 `TEMPLATE.md` 创建本次记录并填写不可变构建信息。
2. 执行内部自动化和静态检查,粘贴摘要而不是完整原始日志。
3. 按功能测试清单分批手测,用户反馈后更新本次记录。
4. 执行性能采样,将审核后的 Markdown 摘要纳入本次记录。
5. 在 `index.md` 登记结论;存在阻断问题时不得标记为可发布。
+64
View File
@@ -0,0 +1,64 @@
# QtDesktopPet 测试记录
## 构建信息
| 项目 | 内容 |
| --- | --- |
| 测试日期 | TODO |
| 版本 / 发布阶段 | TODO |
| Git commit | TODO |
| 工作树状态 | clean / dirty;差异摘要:TODO |
| 构建类型 | Debug / Release |
| 编译器 / Qt | TODO |
| 操作系统 | TODO |
| CPU / 内存 | TODO |
| 显示器 / DPI | TODO |
| 测试人 | TODO |
## 自动化摘要
| 检查 | 结果 | 摘要 |
| --- | --- | --- |
| 核心逻辑测试 | NOT_RUN | TODO |
| 静态检查 | NOT_RUN | TODO |
| GitHub 导出隔离 | NOT_RUN | TODO |
## 手测结果
| 批次 | 范围 | 结果 | 失败 / 阻塞用例 | 备注 |
| --- | --- | --- | --- | --- |
| T01 | 基础窗口与托盘 | NOT_RUN | | |
| T02 | 应用设置与开机自启动 | NOT_RUN | | |
| T03 | 角色管理 | NOT_RUN | | |
| T04 | AI 与聊天历史 | NOT_RUN | | |
| T05 | 定时提醒 | NOT_RUN | | |
| T06 | 天气查询 | NOT_RUN | | |
| T07 | 联网模式 | NOT_RUN | | |
| T08 | 已移除能力与安全边界 | NOT_RUN | | |
| T09 | 配置损坏与隐私 | NOT_RUN | | |
| T10 | 打包、安装与卸载 | NOT_RUN | | |
## 性能摘要
| 场景 | CSV 时间 | 报告结论 | 关键指标 | 备注 |
| --- | --- | --- | --- | --- |
| idle-5m | NOT_RUN | NOT_RUN | | |
| idle-10m | NOT_RUN | NOT_RUN | | |
| hidden-5m | NOT_RUN | NOT_RUN | | |
| ai-20-turns | NOT_RUN | NOT_RUN | | |
| reminder-trigger | NOT_RUN | NOT_RUN | | |
| weather-queries | NOT_RUN | NOT_RUN | | |
| web-queries | NOT_RUN | NOT_RUN | | |
## 缺陷与复测
| 缺陷 ID | 首次现象 | 严重级别 | 修复 commit | 复测结果 | 备注 |
| --- | --- | --- | --- | --- | --- |
| | | | | | |
## 最终结论
- 结论:`BLOCKED / CONDITIONAL PASS / PASS`
- 未执行项:TODO
- 已知风险:TODO
- 是否允许发布:否 / 是
+8
View File
@@ -0,0 +1,8 @@
# QtDesktopPet 测试记录索引
| 日期 | 版本 / 阶段 | Commit | 构建 | 自动化 | 手测 | 性能 | 发布结论 | 记录 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2026-07-17 至 2026-07-18;2026-09-08 至 2026-09-09 复测 | `0.2.0` 发布候选 | 本记录所在提交 | Release / Qt 6.5.3 / MSVC 19.44 | 静态 14/14;C++ 43/43 | 72 PASS / 0 FAIL / 1 WAIVED;T07 无 Key 未测 | NOT_RUN | PASS_WITH_LIMITATIONS | `2026-07-17_pre-release_d0d1de4-dirty.md` |
| 2026-07-17 | QA 基础设施 | `d0d1de4-dirty` | 未构建 | 静态 13/13;C++ NOT_RUN | NOT_RUN | fixture PASS | NOT EVALUATED | `2026-07-17_qa-infrastructure_d0d1de4-dirty.md` |
正式记录按时间倒序添加。模板和维护规则分别见 `TEMPLATE.md` 与 `README.md`。
+18 -5
View File
@@ -1,9 +1,9 @@
#define AppName "QtDesktopPet" #define AppName "QtDesktopPet"
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.1.0" #define AppVersion "0.2.0"
#endif #endif
#ifndef SourceDir #ifndef SourceDir
#define SourceDir "..\dist\QtDesktopPet-0.1.0-windows-x64" #define SourceDir "..\dist\QtDesktopPet-0.2.0-windows-x64"
#endif #endif
#ifndef OutputDir #ifndef OutputDir
#define OutputDir "..\dist\installer" #define OutputDir "..\dist\installer"
@@ -22,25 +22,33 @@ OutputBaseFilename=QtDesktopPet-{#AppVersion}-windows-x64-setup
Compression=lzma2 Compression=lzma2
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
ShowLanguageDialog=no
PrivilegesRequired=lowest PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
SetupIconFile=..\resources\icons\app_icon.ico SetupIconFile=..\resources\icons\app_icon.ico
UninstallDisplayIcon={app}\QtDesktopPet.exe UninstallDisplayIcon={app}\QtDesktopPet.exe
[Languages]
Name: "chinesesimplified"; MessagesFile: ".\languages\ChineseSimplified.isl"
[Tasks] [Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加快捷方式:"; Flags: unchecked
Name: "startup"; Description: "开机自启动"; GroupDescription: "附加选项:"; Flags: unchecked
[Files] [Files]
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Registry]
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#AppName}"; ValueData: """{app}\QtDesktopPet.exe"""; Flags: uninsdeletevalue; Tasks: startup
[Icons] [Icons]
Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}" Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"
Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}" Name: "{group}\卸载 QtDesktopPet"; Filename: "{uninstallexe}"
Name: "{userdesktop}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"; Tasks: desktopicon Name: "{userdesktop}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"; Tasks: desktopicon
[Run] [Run]
Filename: "{app}\QtDesktopPet.exe"; Description: "Launch QtDesktopPet"; Flags: nowait postinstall skipifsilent Filename: "{app}\QtDesktopPet.exe"; Description: "启动 QtDesktopPet"; Flags: nowait postinstall skipifsilent
[Code] [Code]
var var
@@ -79,4 +87,9 @@ begin
DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet')); DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet'));
DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet')); DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet'));
end; end;
if CurUninstallStep = usPostUninstall then
begin
RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', '{#AppName}');
end;
end; end;
+417
View File
@@ -0,0 +1,417 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintainer: Zhenghan Yang (Kira)
; Email: 847320916@QQ.com
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; Encoding: UTF-8
; Translation based on network resource
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; LanguageCodePage should always be set if possible, even if this file is Unicode
; For English it's set to zero anyway because English only uses ASCII characters
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** Application titles
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** Startup error messages
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序已在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** Startup questions
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
; *** Misc. errors
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。
; *** Setup common messages
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** Buttons
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** "Select Language" dialog messages
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** Common wizard text
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** "Welcome" wizard page
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** "Password" wizard page
WizardPassword=密码
PasswordLabel1=此安装程序需要密码验证。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** "License Agreement" wizard page
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** "Information" wizard pages
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** "User Information" wizard page
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=请输入用户名。
; *** "Select Destination Location" wizard page
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** "Select Components" wizard page
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** "Select Additional Tasks" wizard page
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** "Select Start Menu Folder" wizard page
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名称。
GroupNameTooLong=文件夹名称或路径太长。
InvalidGroupName=文件夹名称无效。
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** "Ready to Install" wizard page
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止。
ErrorDownloadFailed=下载失败:%1 %2。
ErrorDownloadSizeFailed=获取大小失败:%1 %2。
ErrorProgress=无效的进度:%1 / %2。
ErrorFileSize=文件大小错误:预期 %1,实际 %2。
; *** TExtractionWizardPage wizard page and ExtractArchive
ExtractingLabel=正在提取文件...
ButtonStopExtraction=停止提取(&S)
StopExtraction=您确定要停止提取吗?
ErrorExtractionAborted=提取已中止。
ErrorExtractionFailed=提取失败:%1
; *** Archive extraction failure details
ArchiveIncorrectPassword=密码不正确。
ArchiveIsCorrupted=压缩包已损坏。
ArchiveUnsupportedFormat=不支持的压缩包格式。
; *** "Preparing to Install" wizard page
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
; *** "Installing" wizard page
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
; *** "Setup Completed" wizard page
FinishedHeadingLabel=完成 [name] 安装向导
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启计算机(&Y)
NoRadio=否,稍后重启计算机(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** "Setup Needs the Next Disk" stuff
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** Installation phase messages
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=取消安装
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消
; *** Installation status messages
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** Misc. errors
ErrorInternal2=内部错误:%1。
ErrorFunctionFailedNoCode=%1 失败。
ErrorFunctionFailed=%1 失败;错误代码 %2。
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** Registry errors
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI errors
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** File copying errors
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
SourceIsCorrupted=源文件已损坏。
SourceDoesntExist=源文件“%1”不存在。
SourceVerificationFailed=源文件验证失败:%1
VerificationSignatureDoesntExist=签名文件“%1”不存在。
VerificationSignatureInvalid=签名文件“%1”无效。
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。
VerificationFileNameIncorrect=文件名不正确。
VerificationFileTagIncorrect=文件标签不正确。
VerificationFileSizeIncorrect=文件大小不正确。
VerificationFileHashIncorrect=文件哈希值不正确。
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留已存在的文件(&K)
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=尝试下载文件时出错:
ErrorExtracting=尝试提取压缩包时出错:
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。
ErrorRegisterTypeLib=无法注册类型库:%1
; *** Uninstall display name markings
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** Post-installation errors
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
; *** Uninstaller messages
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
UninstalledAll=已顺利从您的计算机中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载。
; *** Uninstallation phase messages
ConfirmDeleteSharedFileTitle=删除共享文件?
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 - 2020 kirakira
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# Inno Setup 简体中文翻译
`ChineseSimplified.isl` 来源:
https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
该翻译适用于 Inno Setup 6.5 及以上版本,按同目录 `LICENSE.txt` 中的 MIT 许可证使用。
+81
View File
@@ -0,0 +1,81 @@
cmake_minimum_required(VERSION 3.20)
project(QtDesktopPetInternalQA LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_AUTOMOC OFF)
get_filename_component(DEFAULT_PET_SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
set(PET_SOURCE_ROOT "${DEFAULT_PET_SOURCE_ROOT}" CACHE PATH "QtDesktopPet source root")
if (NOT EXISTS "${PET_SOURCE_ROOT}/src" OR NOT EXISTS "${PET_SOURCE_ROOT}/CMakeLists.txt")
message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}")
endif()
find_package(Qt6 REQUIRED COMPONENTS Core Network)
add_executable(QtDesktopPetCoreTests
include/TestHarness.h
tests/TestMain.cpp
tests/assistant/IntentRoutingTests.cpp
tests/reminder/ReminderTests.cpp
tests/weather/WeatherTests.cpp
tests/web/WebCapabilityTests.cpp
tests/ai/ConversationStoreTests.cpp
tests/ai/AIDiagnosticsTests.cpp
tests/config/ConfigRecoveryTests.cpp
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.cpp
${PET_SOURCE_ROOT}/src/assistant/UserIntent.h
${PET_SOURCE_ROOT}/src/reminder/ReminderParser.h
${PET_SOURCE_ROOT}/src/reminder/ReminderParser.cpp
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.h
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherParser.h
${PET_SOURCE_ROOT}/src/weather/WeatherParser.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.h
${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherManager.h
${PET_SOURCE_ROOT}/src/weather/WeatherManager.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp
${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h
${PET_SOURCE_ROOT}/src/weather/WeatherConfig.h
${PET_SOURCE_ROOT}/src/web/WebCapabilityDetector.h
${PET_SOURCE_ROOT}/src/web/WebCapabilityDetector.cpp
${PET_SOURCE_ROOT}/src/web/WebChatTypes.h
${PET_SOURCE_ROOT}/src/web/WebConfig.h
${PET_SOURCE_ROOT}/src/config/AIConfig.h
${PET_SOURCE_ROOT}/src/config/AIConfig.cpp
${PET_SOURCE_ROOT}/src/config/AppConfig.h
${PET_SOURCE_ROOT}/src/config/ConfigManager.h
${PET_SOURCE_ROOT}/src/config/ConfigManager.cpp
${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.h
${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.cpp
${PET_SOURCE_ROOT}/src/ai/ConversationStore.h
${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp
${PET_SOURCE_ROOT}/src/ai/LLMTypes.h
${PET_SOURCE_ROOT}/src/weather/WeatherStore.h
${PET_SOURCE_ROOT}/src/weather/WeatherStore.cpp
${PET_SOURCE_ROOT}/src/web/WebStore.h
${PET_SOURCE_ROOT}/src/web/WebStore.cpp
${PET_SOURCE_ROOT}/src/reminder/ReminderStore.h
${PET_SOURCE_ROOT}/src/reminder/ReminderStore.cpp
${PET_SOURCE_ROOT}/src/util/Logger.h
${PET_SOURCE_ROOT}/src/util/Logger.cpp
)
target_include_directories(QtDesktopPetCoreTests
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${PET_SOURCE_ROOT}"
)
target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core Qt6::Network)
include(CTest)
add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests)
+63
View File
@@ -0,0 +1,63 @@
# QtDesktopPet Internal QA
This directory contains development-only quality assurance assets. It is tracked
by the development repository and excluded from the public GitHub export and
release packages.
## Structure
- `tests/`: deterministic C++ tests grouped by production module.
- `include/`: the lightweight test harness. It does not require `Q_OBJECT`.
- `fixtures/`: sanitized, versioned test data.
- `config/`: extensible static-check and performance threshold configuration.
- `scripts/`: static checks, performance sampling, and report generation.
- `out/`: local generated output. This directory is ignored by Git.
## Core Logic Tests
The QA project is intentionally independent from the production CMake project.
Configure it from this directory when a matching Qt toolchain is available:
```powershell
cmake -S internal/qa -B build/internal-qa `
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64
cmake --build build/internal-qa
ctest --test-dir build/internal-qa --output-on-failure
```
The test process enables Qt test mode and uses temporary directories for file
fixtures. It must not read or modify the normal QtDesktopPet user configuration.
To isolate a suite while diagnosing failures, run the test executable with a
case-insensitive filter, for example `QtDesktopPetCoreTests.exe --filter=Weather`.
## Static Checks
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File internal/qa/scripts/Invoke-StaticChecks.ps1
```
The script returns a non-zero exit code when a required invariant fails. Extend
`config/static-checks.json` when modules or public-export boundaries change.
## Performance
Start QtDesktopPet, then sample a named scenario:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File internal/qa/scripts/perf_sample.ps1 `
-Scenario idle-5m -DurationSeconds 300
```
Generate a Markdown summary from the resulting CSV:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File internal/qa/scripts/New-PerformanceReport.ps1 `
-InputPath reports/perf/<sample>.csv `
-Scenario idle-5m
```
Raw CSV files stay under `reports/perf/` and are not committed. Reviewed,
sanitized summaries belong in `docs/test-records/`.
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"defaults": {
"allowUnexpectedExit": false,
"maxUnresponsiveSamples": 0,
"maxAverageCpuPercent": null,
"maxP95CpuPercent": null,
"maxPrivateMemoryGrowthMB": null,
"maxWorkingSetGrowthMB": null,
"maxHandleGrowth": null,
"maxThreadGrowth": null
},
"scenarios": {
"idle-5m": {},
"idle-10m": {},
"hidden-5m": {},
"ai-20-turns": {},
"reminder-trigger": {},
"weather-queries": {},
"web-queries": {}
}
}
+108
View File
@@ -0,0 +1,108 @@
{
"schemaVersion": 1,
"productionSourceRoots": [
"src"
],
"productionExtensions": [
".h",
".cpp"
],
"productionCmakeFile": "CMakeLists.txt",
"qaCmakeFile": "internal/qa/CMakeLists.txt",
"qaTestRoots": [
"internal/qa/tests"
],
"activeScanPaths": [
"CMakeLists.txt",
"main.cpp",
"src"
],
"forbiddenReferences": [
{
"name": "removed module paths",
"pattern": "src[\\\\/](?:fileops|launcher|workspace|search)[\\\\/]"
},
{
"name": "removed module types",
"pattern": "\\b(?:FileOperationManager|FileSandbox|AppLaunchManager|AppDiscovery|WorkspaceController|WorkspaceAgent|WorkspaceAgentWindow|WorkspacePanel|GlobalHotkeyManager|WebSearchManager|SearchAnswerSynthesizer)\\b"
},
{
"name": "removed intent actions",
"pattern": "\\b(?:UserIntentType|CommandDispatchAction)::(?:FileOperation|LaunchApp|Workspace|Search|UnsupportedTool)\\b"
}
],
"forbidQObjectMacro": true,
"requiredPaths": [
"README.md",
"LICENSE",
"installer/QtDesktopPet.iss",
"resources/characters/shiroko/character.json",
"resources/characters/shiroko/preview.png",
"resources/icons/app_icon.ico",
"resources/sounds/reminders/reminder_default.wav",
"installer/languages/ChineseSimplified.isl",
"installer/languages/LICENSE.txt",
"tools/package_release.ps1",
"tools/prepare_github_export.ps1"
],
"publicExportExcludedRoots": [
"docs",
"internal",
"reports"
],
"releaseIsolationFiles": [
"CMakeLists.txt",
"tools/package_release.ps1"
],
"releaseForbiddenPatterns": [
"internal/qa",
"internal\\\\qa"
],
"releaseValidation": {
"powershellParseFiles": [
"internal/qa/scripts/Invoke-StaticChecks.ps1",
"internal/qa/scripts/New-PerformanceReport.ps1",
"internal/qa/scripts/perf_sample.ps1",
"tools/package_release.ps1",
"tools/prepare_github_export.ps1"
],
"packageScript": {
"path": "tools/package_release.ps1",
"requiredPatterns": [
{ "name": "Qt deployment tool", "pattern": "windeployqt" },
{ "name": "release runtime deployment", "pattern": "--release" },
{ "name": "compiler runtime deployment", "pattern": "--compiler-runtime" },
{ "name": "MSVC app-local deployment", "pattern": "Copy-MsvcRuntime" },
{ "name": "MSVC runtime input", "pattern": "MsvcRuntimeDir" },
{ "name": "MSVC runtime validation", "pattern": "vcruntime140_1\\.dll" },
{ "name": "character resources", "pattern": "resources\\\\characters" },
{ "name": "icon resources", "pattern": "resources\\\\icons" },
{ "name": "sound resources", "pattern": "resources\\\\sounds" },
{ "name": "README", "pattern": "README\\.md" },
{ "name": "LICENSE", "pattern": "LICENSE" },
{ "name": "installer build switch", "pattern": "BuildInstaller" },
{ "name": "Inno Setup compiler", "pattern": "ISCC\\.exe" }
],
"forbiddenPatterns": [
{ "name": "internal QA copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*internal[\\\\/]qa" },
{ "name": "development docs copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*docs" },
{ "name": "test reports copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*reports" }
]
},
"installerScript": {
"path": "installer/QtDesktopPet.iss",
"requiredPatterns": [
{ "name": "per-user install", "pattern": "PrivilegesRequired=lowest" },
{ "name": "x64 compatibility", "pattern": "ArchitecturesAllowed=x64compatible" },
{ "name": "Simplified Chinese installer", "pattern": "ChineseSimplified\\.isl" },
{ "name": "language dialog disabled", "pattern": "ShowLanguageDialog=no" },
{ "name": "unchecked startup option", "pattern": "Name:\\s*\"startup\"[^\\r\\n]*Flags:\\s*unchecked" },
{ "name": "startup registry entry", "pattern": "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run" },
{ "name": "recursive package files", "pattern": "recursesubdirs\\s+createallsubdirs" },
{ "name": "uninstall data prompt", "pattern": "InitializeUninstall" },
{ "name": "optional user data cleanup", "pattern": "DeleteUserDataAfterUninstall" },
{ "name": "startup registry cleanup", "pattern": "RegDeleteValue" }
]
}
}
}
@@ -0,0 +1,4 @@
"Scenario","SampleIndex","TimestampUtc","Pid","CpuPercent","WorkingSetMB","PrivateMemoryMB","HandleCount","ThreadCount","Responding","Path","Status"
"fixture-stable","0","2026-07-17T00:00:00.0000000Z","1000","0","100.0","80.0","200","12","True","C:\\Fixture\\QtDesktopPet.exe","running"
"fixture-stable","1","2026-07-17T00:00:05.0000000Z","1000","1.5","101.0","80.5","201","12","True","C:\\Fixture\\QtDesktopPet.exe","running"
"fixture-stable","2","2026-07-17T00:00:10.0000000Z","1000","2.5","102.0","81.0","202","13","True","C:\\Fixture\\QtDesktopPet.exe","running"
1 Scenario SampleIndex TimestampUtc Pid CpuPercent WorkingSetMB PrivateMemoryMB HandleCount ThreadCount Responding Path Status
2 fixture-stable 0 2026-07-17T00:00:00.0000000Z 1000 0 100.0 80.0 200 12 True C:\\Fixture\\QtDesktopPet.exe running
3 fixture-stable 1 2026-07-17T00:00:05.0000000Z 1000 1.5 101.0 80.5 201 12 True C:\\Fixture\\QtDesktopPet.exe running
4 fixture-stable 2 2026-07-17T00:00:10.0000000Z 1000 2.5 102.0 81.0 202 13 True C:\\Fixture\\QtDesktopPet.exe running
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <functional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace Qa
{
struct TestCase
{
std::string name;
std::function<void()> function;
};
inline std::vector<TestCase> &registry()
{
static std::vector<TestCase> tests;
return tests;
}
class Registrar
{
public:
Registrar(const char *name, std::function<void()> function)
{
registry().push_back({name, std::move(function)});
}
};
[[noreturn]] inline void fail(const char *expression, const char *file, int line)
{
std::ostringstream stream;
stream << file << ':' << line << ": expectation failed: " << expression;
throw std::runtime_error(stream.str());
}
}
#define QA_JOIN_IMPL(left, right) left##right
#define QA_JOIN(left, right) QA_JOIN_IMPL(left, right)
#define QA_TEST(suiteName, testName) \
static void QA_JOIN(qa_test_function_, __LINE__)(); \
static Qa::Registrar QA_JOIN(qa_test_registrar_, __LINE__)( \
#suiteName "." #testName, QA_JOIN(qa_test_function_, __LINE__)); \
static void QA_JOIN(qa_test_function_, __LINE__)()
#define QA_EXPECT(expression) \
do \
{ \
if (!(expression)) \
{ \
Qa::fail(#expression, __FILE__, __LINE__); \
} \
} while (false)
#define QA_EXPECT_EQ(actual, expected) \
do \
{ \
const auto qaActualValue = (actual); \
const auto qaExpectedValue = (expected); \
if (!(qaActualValue == qaExpectedValue)) \
{ \
Qa::fail(#actual " == " #expected, __FILE__, __LINE__); \
} \
} while (false)
+349
View File
@@ -0,0 +1,349 @@
param(
[string]$RepoRoot = "",
[string]$ConfigPath = "",
[string]$ReportPath = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-AbsolutePath {
param(
[string]$Path,
[string]$BasePath
)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
}
function Get-RelativePath {
param(
[string]$Root,
[string]$Path
)
$rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$fullPath = [System.IO.Path]::GetFullPath($Path)
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Path is outside repository root: $fullPath"
}
return $fullPath.Substring($rootPrefix.Length).Replace('\', '/')
}
function Get-TextFiles {
param(
[string]$Root,
[object[]]$Paths
)
$allowedExtensions = @(".h", ".hpp", ".cpp", ".cxx", ".md", ".txt", ".json", ".cmake", ".ps1")
$files = New-Object System.Collections.Generic.List[System.IO.FileInfo]
foreach ($relativePath in $Paths) {
$absolutePath = Resolve-AbsolutePath -Path ([string]$relativePath) -BasePath $Root
if (-not (Test-Path -LiteralPath $absolutePath)) {
continue
}
$item = Get-Item -LiteralPath $absolutePath
if (-not $item.PSIsContainer) {
$files.Add($item)
continue
}
foreach ($file in Get-ChildItem -LiteralPath $absolutePath -Recurse -File) {
if ($allowedExtensions -contains $file.Extension.ToLowerInvariant()) {
$files.Add($file)
}
}
}
return @($files | Sort-Object FullName -Unique)
}
function Invoke-GitDiffCheck {
param([string]$Root)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = "git"
$escapedRoot = $Root.Replace('"', '\"')
$startInfo.Arguments = "-C `"$escapedRoot`" diff --check"
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "Unable to start git for diff validation."
}
$standardOutput = $process.StandardOutput.ReadToEnd()
$standardError = $process.StandardError.ReadToEnd()
$process.WaitForExit()
return [pscustomobject]@{
ExitCode = $process.ExitCode
Output = ($standardOutput + $standardError).Trim()
}
}
$defaultRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
$resolvedRoot = if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
$defaultRoot
} else {
Resolve-AbsolutePath -Path $RepoRoot -BasePath (Get-Location)
}
$resolvedConfig = if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
Join-Path $resolvedRoot "internal/qa/config/static-checks.json"
} else {
Resolve-AbsolutePath -Path $ConfigPath -BasePath $resolvedRoot
}
if (-not (Test-Path -LiteralPath $resolvedConfig -PathType Leaf)) {
throw "Static-check configuration was not found: $resolvedConfig"
}
$config = Get-Content -LiteralPath $resolvedConfig -Raw -Encoding UTF8 | ConvertFrom-Json
if ($config.schemaVersion -ne 1) {
throw "Unsupported static-check configuration version: $($config.schemaVersion)"
}
$results = New-Object System.Collections.Generic.List[object]
function Add-Result {
param(
[string]$Name,
[bool]$Passed,
[string]$Details
)
$results.Add([pscustomobject]@{
Name = $Name
Passed = $Passed
Details = $Details
})
}
$cmakePath = Resolve-AbsolutePath -Path ([string]$config.productionCmakeFile) -BasePath $resolvedRoot
$cmakeText = Get-Content -LiteralPath $cmakePath -Raw -Encoding UTF8
$productionFiles = New-Object System.Collections.Generic.List[System.IO.FileInfo]
foreach ($sourceRoot in $config.productionSourceRoots) {
$sourceRootPath = Resolve-AbsolutePath -Path ([string]$sourceRoot) -BasePath $resolvedRoot
foreach ($file in Get-ChildItem -LiteralPath $sourceRootPath -Recurse -File) {
if ($config.productionExtensions -contains $file.Extension.ToLowerInvariant()) {
$productionFiles.Add($file)
}
}
}
$missingFromCmake = New-Object System.Collections.Generic.List[string]
foreach ($file in $productionFiles) {
$relativePath = Get-RelativePath -Root $resolvedRoot -Path $file.FullName
if (-not $cmakeText.Contains($relativePath)) {
$missingFromCmake.Add($relativePath)
}
}
Add-Result -Name "Production sources are registered in CMake" `
-Passed ($missingFromCmake.Count -eq 0) `
-Details ($(if ($missingFromCmake.Count -eq 0) { "$($productionFiles.Count) source/header files registered" } else { "Missing: " + ($missingFromCmake -join ", ") }))
$missingCmakeReferences = New-Object System.Collections.Generic.List[string]
$cmakeReferencePattern = 'src[\\/][A-Za-z0-9_.\\/-]+\.(?:h|cpp)'
foreach ($match in [regex]::Matches($cmakeText, $cmakeReferencePattern)) {
$relativePath = $match.Value.Replace('\', '/')
if (-not (Test-Path -LiteralPath (Join-Path $resolvedRoot $relativePath) -PathType Leaf)) {
$missingCmakeReferences.Add($relativePath)
}
}
Add-Result -Name "CMake does not reference missing production sources" `
-Passed ($missingCmakeReferences.Count -eq 0) `
-Details ($(if ($missingCmakeReferences.Count -eq 0) { "No missing source references" } else { "Missing: " + (($missingCmakeReferences | Sort-Object -Unique) -join ", ") }))
$qaCmakePath = Resolve-AbsolutePath -Path ([string]$config.qaCmakeFile) -BasePath $resolvedRoot
$qaRoot = Split-Path $qaCmakePath -Parent
$qaCmakeText = Get-Content -LiteralPath $qaCmakePath -Raw -Encoding UTF8
$missingQaTests = New-Object System.Collections.Generic.List[string]
$qaTestCount = 0
foreach ($testRoot in $config.qaTestRoots) {
$testRootPath = Resolve-AbsolutePath -Path ([string]$testRoot) -BasePath $resolvedRoot
foreach ($testFile in Get-ChildItem -LiteralPath $testRootPath -Recurse -Filter "*.cpp" -File) {
++$qaTestCount
$relativeTestPath = Get-RelativePath -Root $qaRoot -Path $testFile.FullName
if (-not $qaCmakeText.Contains($relativeTestPath)) {
$missingQaTests.Add($relativeTestPath)
}
}
}
Add-Result -Name "Internal QA test sources are registered in the QA CMake project" `
-Passed ($missingQaTests.Count -eq 0) `
-Details ($(if ($missingQaTests.Count -eq 0) { "$qaTestCount test translation units registered" } else { "Missing: " + ($missingQaTests -join ", ") }))
$activeFiles = Get-TextFiles -Root $resolvedRoot -Paths $config.activeScanPaths
foreach ($rule in $config.forbiddenReferences) {
$matches = New-Object System.Collections.Generic.List[string]
foreach ($file in $activeFiles) {
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
if ($content -match $rule.pattern) {
$matches.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
}
}
Add-Result -Name "No $($rule.name)" `
-Passed ($matches.Count -eq 0) `
-Details ($(if ($matches.Count -eq 0) { "No active references" } else { "Found in: " + (($matches | Sort-Object -Unique) -join ", ") }))
}
if ($config.forbidQObjectMacro) {
$qObjectFiles = New-Object System.Collections.Generic.List[string]
foreach ($file in $productionFiles) {
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
if ($content -match '\bQ_OBJECT\b') {
$qObjectFiles.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
}
}
Add-Result -Name "Production code remains compatible with CMAKE_AUTOMOC OFF" `
-Passed ($qObjectFiles.Count -eq 0) `
-Details ($(if ($qObjectFiles.Count -eq 0) { "No Q_OBJECT macro found" } else { "Found in: " + ($qObjectFiles -join ", ") }))
}
$missingRequiredPaths = @($config.requiredPaths | Where-Object {
-not (Test-Path -LiteralPath (Resolve-AbsolutePath -Path ([string]$_) -BasePath $resolvedRoot))
})
Add-Result -Name "Required release inputs exist" `
-Passed ($missingRequiredPaths.Count -eq 0) `
-Details ($(if ($missingRequiredPaths.Count -eq 0) { "$($config.requiredPaths.Count) required paths found" } else { "Missing: " + ($missingRequiredPaths -join ", ") }))
$characterManifestPath = Join-Path $resolvedRoot "resources/characters/shiroko/character.json"
$characterProblems = New-Object System.Collections.Generic.List[string]
try {
$character = Get-Content -LiteralPath $characterManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ([string]::IsNullOrWhiteSpace([string]$character.defaultState)) {
$characterProblems.Add("defaultState is empty")
}
foreach ($stateProperty in $character.states.PSObject.Properties) {
$statePath = Join-Path (Split-Path $characterManifestPath -Parent) ([string]$stateProperty.Value.path)
if (-not (Test-Path -LiteralPath $statePath -PathType Container)) {
$characterProblems.Add("state directory missing: $($stateProperty.Name)")
continue
}
if (@(Get-ChildItem -LiteralPath $statePath -Filter "*.png" -File).Count -eq 0) {
$characterProblems.Add("state has no PNG frames: $($stateProperty.Name)")
}
}
} catch {
$characterProblems.Add("manifest parse failed: $($_.Exception.Message)")
}
Add-Result -Name "Default character manifest and frames are complete" `
-Passed ($characterProblems.Count -eq 0) `
-Details ($(if ($characterProblems.Count -eq 0) { "All declared states contain PNG frames" } else { $characterProblems -join "; " }))
$exportScriptPath = Join-Path $resolvedRoot "tools/prepare_github_export.ps1"
$exportScriptText = Get-Content -LiteralPath $exportScriptPath -Raw -Encoding UTF8
$missingExportExclusions = @($config.publicExportExcludedRoots | Where-Object {
$quotedName = '"' + [regex]::Escape([string]$_) + '"'
$exportScriptText -notmatch $quotedName
})
Add-Result -Name "Public GitHub export excludes internal development roots" `
-Passed ($missingExportExclusions.Count -eq 0) `
-Details ($(if ($missingExportExclusions.Count -eq 0) { "Excluded: " + ($config.publicExportExcludedRoots -join ", ") } else { "Not excluded: " + ($missingExportExclusions -join ", ") }))
$releaseIsolationMatches = New-Object System.Collections.Generic.List[string]
foreach ($relativeFile in $config.releaseIsolationFiles) {
$absoluteFile = Resolve-AbsolutePath -Path ([string]$relativeFile) -BasePath $resolvedRoot
$content = Get-Content -LiteralPath $absoluteFile -Raw -Encoding UTF8
foreach ($pattern in $config.releaseForbiddenPatterns) {
if ($content -match $pattern) {
$releaseIsolationMatches.Add("$relativeFile -> $pattern")
}
}
}
Add-Result -Name "Production build and packaging do not depend on internal QA" `
-Passed ($releaseIsolationMatches.Count -eq 0) `
-Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " }))
$releaseValidationProblems = New-Object System.Collections.Generic.List[string]
$packageValidation = $config.releaseValidation.packageScript
$packageScriptPath = Resolve-AbsolutePath -Path ([string]$packageValidation.path) -BasePath $resolvedRoot
$packageScriptText = Get-Content -LiteralPath $packageScriptPath -Raw -Encoding UTF8
foreach ($rule in $packageValidation.requiredPatterns) {
if ($packageScriptText -notmatch ([string]$rule.pattern)) {
$releaseValidationProblems.Add("package missing $($rule.name)")
}
}
foreach ($rule in $packageValidation.forbiddenPatterns) {
if ($packageScriptText -match ([string]$rule.pattern)) {
$releaseValidationProblems.Add("package contains $($rule.name)")
}
}
$installerValidation = $config.releaseValidation.installerScript
$installerScriptPath = Resolve-AbsolutePath -Path ([string]$installerValidation.path) -BasePath $resolvedRoot
$installerScriptText = Get-Content -LiteralPath $installerScriptPath -Raw -Encoding UTF8
foreach ($rule in $installerValidation.requiredPatterns) {
if ($installerScriptText -notmatch ([string]$rule.pattern)) {
$releaseValidationProblems.Add("installer missing $($rule.name)")
}
}
Add-Result -Name "Release packaging and installer contain required safeguards" `
-Passed ($releaseValidationProblems.Count -eq 0) `
-Details ($(if ($releaseValidationProblems.Count -eq 0) { "Package inputs, QA isolation, startup option, and uninstall cleanup are declared" } else { $releaseValidationProblems -join "; " }))
$infrastructureProblems = New-Object System.Collections.Generic.List[string]
foreach ($relativeScriptPath in $config.releaseValidation.powershellParseFiles) {
$scriptFile = Get-Item -LiteralPath (Resolve-AbsolutePath -Path ([string]$relativeScriptPath) -BasePath $resolvedRoot)
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$scriptFile.FullName,
[ref]$tokens,
[ref]$parseErrors) | Out-Null
foreach ($parseError in $parseErrors) {
$infrastructureProblems.Add("$($scriptFile.Name):$($parseError.Extent.StartLineNumber): $($parseError.Message)")
}
}
foreach ($jsonFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/config") -Filter "*.json" -File) {
try {
Get-Content -LiteralPath $jsonFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
} catch {
$infrastructureProblems.Add("$($jsonFile.Name): $($_.Exception.Message)")
}
}
Add-Result -Name "Internal QA scripts and configuration parse successfully" `
-Passed ($infrastructureProblems.Count -eq 0) `
-Details ($(if ($infrastructureProblems.Count -eq 0) { "All PowerShell and JSON files parsed" } else { $infrastructureProblems -join "; " }))
$gitResult = Invoke-GitDiffCheck -Root $resolvedRoot
Add-Result -Name "Git diff whitespace check" `
-Passed ($gitResult.ExitCode -eq 0) `
-Details ($(if ($gitResult.ExitCode -eq 0) { "git diff --check passed" } else { $gitResult.Output }))
$passedCount = @($results | Where-Object Passed).Count
$failedResults = @($results | Where-Object { -not $_.Passed })
foreach ($result in $results) {
$prefix = if ($result.Passed) { "PASS" } else { "FAIL" }
Write-Host "[$prefix] $($result.Name) - $($result.Details)"
}
Write-Host "Static checks: $passedCount passed, $($failedResults.Count) failed."
if (-not [string]::IsNullOrWhiteSpace($ReportPath)) {
$resolvedReportPath = Resolve-AbsolutePath -Path $ReportPath -BasePath $resolvedRoot
$reportDirectory = Split-Path $resolvedReportPath -Parent
if (-not (Test-Path -LiteralPath $reportDirectory)) {
New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add("# Static Check Report")
$lines.Add("")
$lines.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
$lines.Add("")
$lines.Add("| Check | Result | Details |")
$lines.Add("| --- | --- | --- |")
foreach ($result in $results) {
$status = if ($result.Passed) { "PASS" } else { "FAIL" }
$details = ([string]$result.Details).Replace("|", "\|").Replace("`r", " ").Replace("`n", " ")
$lines.Add("| $($result.Name) | $status | $details |")
}
Set-Content -LiteralPath $resolvedReportPath -Value $lines -Encoding UTF8
Write-Host "Report: $resolvedReportPath"
}
if ($failedResults.Count -gt 0) {
exit 1
}
@@ -0,0 +1,203 @@
param(
[Parameter(Mandatory = $true)]
[string]$InputPath,
[string]$Scenario = "",
[string]$ThresholdConfigPath = "",
[string]$OutputPath = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-AbsolutePath {
param(
[string]$Path,
[string]$BasePath
)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
}
function Convert-ToDouble {
param([object]$Value)
return [double]::Parse(
[string]$Value,
[System.Globalization.NumberStyles]::Float,
[System.Globalization.CultureInfo]::InvariantCulture)
}
function Get-Percentile {
param(
[double[]]$Values,
[double]$Percentile
)
if ($Values.Count -eq 0) { return 0.0 }
$sorted = @($Values | Sort-Object)
$index = [math]::Max(0, [math]::Ceiling($Percentile * $sorted.Count) - 1)
return [double]$sorted[$index]
}
function Get-PropertyValue {
param(
[object]$Object,
[string]$Name
)
if ($null -eq $Object) { return $null }
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) { return $null }
return $property.Value
}
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
$resolvedInput = Resolve-AbsolutePath -Path $InputPath -BasePath $repoRoot
if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) {
throw "Performance CSV was not found: $resolvedInput"
}
$resolvedThresholds = if ([string]::IsNullOrWhiteSpace($ThresholdConfigPath)) {
Join-Path $repoRoot "internal/qa/config/performance-thresholds.json"
} else {
Resolve-AbsolutePath -Path $ThresholdConfigPath -BasePath $repoRoot
}
$thresholdConfig = Get-Content -LiteralPath $resolvedThresholds -Raw -Encoding UTF8 | ConvertFrom-Json
if ($thresholdConfig.schemaVersion -ne 1) {
throw "Unsupported performance threshold configuration version: $($thresholdConfig.schemaVersion)"
}
$allRows = @(Import-Csv -LiteralPath $resolvedInput)
$runningRows = @($allRows | Where-Object { $_.Status -eq "running" })
if ($runningRows.Count -eq 0) {
throw "Performance CSV contains no running samples: $resolvedInput"
}
if ([string]::IsNullOrWhiteSpace($Scenario)) {
$scenarioProperty = $runningRows[0].PSObject.Properties["Scenario"]
$Scenario = if ($null -ne $scenarioProperty -and -not [string]::IsNullOrWhiteSpace([string]$scenarioProperty.Value)) {
[string]$scenarioProperty.Value
} else {
[System.IO.Path]::GetFileNameWithoutExtension($resolvedInput)
}
}
$cpuValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.CpuPercent })
$workingSetValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.WorkingSetMB })
$privateMemoryValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.PrivateMemoryMB })
$handleValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.HandleCount })
$threadValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.ThreadCount })
$metrics = [ordered]@{
SampleCount = $runningRows.Count
AverageCpuPercent = [math]::Round(($cpuValues | Measure-Object -Average).Average, 2)
P95CpuPercent = [math]::Round((Get-Percentile -Values $cpuValues -Percentile 0.95), 2)
PeakCpuPercent = [math]::Round(($cpuValues | Measure-Object -Maximum).Maximum, 2)
StartWorkingSetMB = $workingSetValues[0]
EndWorkingSetMB = $workingSetValues[-1]
PeakWorkingSetMB = [math]::Round(($workingSetValues | Measure-Object -Maximum).Maximum, 2)
WorkingSetGrowthMB = [math]::Round($workingSetValues[-1] - $workingSetValues[0], 2)
StartPrivateMemoryMB = $privateMemoryValues[0]
EndPrivateMemoryMB = $privateMemoryValues[-1]
PeakPrivateMemoryMB = [math]::Round(($privateMemoryValues | Measure-Object -Maximum).Maximum, 2)
PrivateMemoryGrowthMB = [math]::Round($privateMemoryValues[-1] - $privateMemoryValues[0], 2)
HandleGrowth = [int]($handleValues[-1] - $handleValues[0])
PeakHandleCount = [int](($handleValues | Measure-Object -Maximum).Maximum)
ThreadGrowth = [int]($threadValues[-1] - $threadValues[0])
PeakThreadCount = [int](($threadValues | Measure-Object -Maximum).Maximum)
UnresponsiveSamples = @($runningRows | Where-Object { ([string]$_.Responding).ToLowerInvariant() -eq "false" }).Count
UnexpectedExit = @($allRows | Where-Object { $_.Status -eq "exited" }).Count -gt 0
}
$scenarioThresholds = Get-PropertyValue -Object $thresholdConfig.scenarios -Name $Scenario
$effectiveThresholds = @{}
foreach ($property in $thresholdConfig.defaults.PSObject.Properties) {
$effectiveThresholds[$property.Name] = $property.Value
}
if ($null -ne $scenarioThresholds) {
foreach ($property in $scenarioThresholds.PSObject.Properties) {
$effectiveThresholds[$property.Name] = $property.Value
}
}
$checks = New-Object System.Collections.Generic.List[object]
function Add-ThresholdCheck {
param(
[string]$Name,
[double]$Actual,
[string]$ThresholdName,
[string]$Unit
)
$threshold = $effectiveThresholds[$ThresholdName]
if ($null -eq $threshold) { return }
$limit = Convert-ToDouble $threshold
$checks.Add([pscustomobject]@{
Name = $Name
Passed = $Actual -le $limit
Actual = "$Actual$Unit"
Limit = "<= $limit$Unit"
})
}
$allowUnexpectedExit = [bool]$effectiveThresholds["allowUnexpectedExit"]
$checks.Add([pscustomobject]@{
Name = "Unexpected process exit"
Passed = $allowUnexpectedExit -or -not $metrics.UnexpectedExit
Actual = [string]$metrics.UnexpectedExit
Limit = if ($allowUnexpectedExit) { "allowed" } else { "False" }
})
Add-ThresholdCheck -Name "Unresponsive samples" -Actual $metrics.UnresponsiveSamples -ThresholdName "maxUnresponsiveSamples" -Unit ""
Add-ThresholdCheck -Name "Average CPU" -Actual $metrics.AverageCpuPercent -ThresholdName "maxAverageCpuPercent" -Unit "%"
Add-ThresholdCheck -Name "P95 CPU" -Actual $metrics.P95CpuPercent -ThresholdName "maxP95CpuPercent" -Unit "%"
Add-ThresholdCheck -Name "Private memory growth" -Actual $metrics.PrivateMemoryGrowthMB -ThresholdName "maxPrivateMemoryGrowthMB" -Unit " MB"
Add-ThresholdCheck -Name "Working set growth" -Actual $metrics.WorkingSetGrowthMB -ThresholdName "maxWorkingSetGrowthMB" -Unit " MB"
Add-ThresholdCheck -Name "Handle growth" -Actual $metrics.HandleGrowth -ThresholdName "maxHandleGrowth" -Unit ""
Add-ThresholdCheck -Name "Thread growth" -Actual $metrics.ThreadGrowth -ThresholdName "maxThreadGrowth" -Unit ""
$failedChecks = @($checks | Where-Object { -not $_.Passed })
$result = if ($failedChecks.Count -eq 0) { "PASS" } else { "FAIL" }
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$safeScenario = $Scenario -replace '[^a-zA-Z0-9._-]', '_'
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$OutputPath = "internal/qa/out/performance/$timestamp-$safeScenario.md"
}
$resolvedOutput = Resolve-AbsolutePath -Path $OutputPath -BasePath $repoRoot
$outputDirectory = Split-Path $resolvedOutput -Parent
if (-not (Test-Path -LiteralPath $outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add("# Performance Summary")
$lines.Add("")
$lines.Add("- Scenario: ``$Scenario``")
$lines.Add("- Result: **$result**")
$lines.Add("- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
$lines.Add("- Source CSV: ``$resolvedInput``")
$lines.Add("")
$lines.Add("## Metrics")
$lines.Add("")
$lines.Add("| Metric | Value |")
$lines.Add("| --- | ---: |")
foreach ($entry in $metrics.GetEnumerator()) {
$lines.Add("| $($entry.Key) | $($entry.Value) |")
}
$lines.Add("")
$lines.Add("## Threshold Checks")
$lines.Add("")
$lines.Add("| Check | Result | Actual | Limit |")
$lines.Add("| --- | --- | ---: | ---: |")
foreach ($check in $checks) {
$status = if ($check.Passed) { "PASS" } else { "FAIL" }
$lines.Add("| $($check.Name) | $status | $($check.Actual) | $($check.Limit) |")
}
$lines.Add("")
$lines.Add("Resource thresholds left as ``null`` are reported but not used as release gates. Calibrate them after a reviewed Release baseline is available.")
Set-Content -LiteralPath $resolvedOutput -Value $lines -Encoding UTF8
Write-Host "Performance report: $resolvedOutput"
Write-Host "Result: $result"
if ($failedChecks.Count -gt 0) {
exit 1
}
@@ -6,6 +6,7 @@ param(
[int]$IntervalSeconds = 5, [int]$IntervalSeconds = 5,
[ValidateRange(1, 604800)] [ValidateRange(1, 604800)]
[int]$DurationSeconds = 300, [int]$DurationSeconds = 300,
[string]$Scenario = "unspecified",
[string]$OutputPath = "reports/perf" [string]$OutputPath = "reports/perf"
) )
@@ -35,39 +36,37 @@ function Resolve-TargetProcess {
} }
function Read-ProcessSnapshot { function Read-ProcessSnapshot {
param( param([System.Diagnostics.Process]$Process)
[System.Diagnostics.Process]$Process
)
$path = "" $path = ""
try { try { $path = $Process.Path } catch { $path = "" }
$path = $Process.Path
}
catch {
$path = ""
}
$responding = "" $responding = ""
try { try { $responding = $Process.Responding } catch { $responding = "" }
$responding = $Process.Responding
}
catch {
$responding = ""
}
return [pscustomobject]@{ return [pscustomobject]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o") TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
Pid = $Process.Id Pid = $Process.Id
CpuSeconds = [double]($Process.CPU) CpuSeconds = [double]($Process.CPU)
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2) WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2) PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
HandleCount = $Process.HandleCount HandleCount = $Process.HandleCount
ThreadCount = $Process.Threads.Count ThreadCount = $Process.Threads.Count
Responding = $responding Responding = $responding
Path = $path Path = $path
} }
} }
function Resolve-OutputDirectory {
param([string]$Path)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
function New-OutputFilePath { function New-OutputFilePath {
param( param(
[string]$Directory, [string]$Directory,
@@ -87,37 +86,35 @@ $target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
if ($null -eq $target) { if ($null -eq $target) {
if ($ProcessId -gt 0) { if ($ProcessId -gt 0) {
Write-Error "Process with PID $ProcessId was not found." Write-Error "Process with PID $ProcessId was not found."
} } else {
else {
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid." Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
} }
exit 1 exit 1
} }
$logicalProcessorCount = [Environment]::ProcessorCount $logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount)
if ($logicalProcessorCount -lt 1) { $outputDirectory = Resolve-OutputDirectory -Path $OutputPath
$logicalProcessorCount = 1 $outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)"
}
$outputFile = New-OutputFilePath -Directory $OutputPath -Name $target.ProcessName
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds)) $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 "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
Write-Host "Output: $outputFile" Write-Host "Output: $outputFile"
$previousSnapshot = Read-ProcessSnapshot -Process $target $previousSnapshot = Read-ProcessSnapshot -Process $target
$previousTime = Get-Date $previousTime = Get-Date
$firstRow = [pscustomobject]@{ $firstRow = [pscustomobject]@{
TimestampUtc = $previousSnapshot.TimestampUtc Scenario = $Scenario
Pid = $previousSnapshot.Pid SampleIndex = 0
CpuPercent = 0 TimestampUtc = $previousSnapshot.TimestampUtc
WorkingSetMB = $previousSnapshot.WorkingSetMB Pid = $previousSnapshot.Pid
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB CpuPercent = 0
HandleCount = $previousSnapshot.HandleCount WorkingSetMB = $previousSnapshot.WorkingSetMB
ThreadCount = $previousSnapshot.ThreadCount PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
Responding = $previousSnapshot.Responding HandleCount = $previousSnapshot.HandleCount
Path = $previousSnapshot.Path ThreadCount = $previousSnapshot.ThreadCount
Status = "running" Responding = $previousSnapshot.Responding
Path = $previousSnapshot.Path
Status = "running"
} }
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 $firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
@@ -127,16 +124,18 @@ for ($index = 1; $index -le $sampleCount; $index++) {
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue $currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
if ($null -eq $currentProcess) { if ($null -eq $currentProcess) {
[pscustomobject]@{ [pscustomobject]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o") Scenario = $Scenario
Pid = $target.Id SampleIndex = $index
CpuPercent = "" TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
WorkingSetMB = "" Pid = $target.Id
PrivateMemoryMB = "" CpuPercent = ""
HandleCount = "" WorkingSetMB = ""
ThreadCount = "" PrivateMemoryMB = ""
Responding = "" HandleCount = ""
Path = $previousSnapshot.Path ThreadCount = ""
Status = "exited" Responding = ""
Path = $previousSnapshot.Path
Status = "exited"
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append } | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
Write-Warning "Process $($target.Id) exited. Sampling stopped." Write-Warning "Process $($target.Id) exited. Sampling stopped."
@@ -147,25 +146,25 @@ for ($index = 1; $index -le $sampleCount; $index++) {
$currentTime = Get-Date $currentTime = Get-Date
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds) $elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount $cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
if ($cpuPercent -lt 0) { if ($cpuPercent -lt 0) { $cpuPercent = 0 }
$cpuPercent = 0
}
[pscustomobject]@{ [pscustomobject]@{
TimestampUtc = $currentSnapshot.TimestampUtc Scenario = $Scenario
Pid = $currentSnapshot.Pid SampleIndex = $index
CpuPercent = [math]::Round($cpuPercent, 2) TimestampUtc = $currentSnapshot.TimestampUtc
WorkingSetMB = $currentSnapshot.WorkingSetMB Pid = $currentSnapshot.Pid
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB CpuPercent = [math]::Round($cpuPercent, 2)
HandleCount = $currentSnapshot.HandleCount WorkingSetMB = $currentSnapshot.WorkingSetMB
ThreadCount = $currentSnapshot.ThreadCount PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
Responding = $currentSnapshot.Responding HandleCount = $currentSnapshot.HandleCount
Path = $currentSnapshot.Path ThreadCount = $currentSnapshot.ThreadCount
Status = "running" Responding = $currentSnapshot.Responding
Path = $currentSnapshot.Path
Status = "running"
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append } | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
$previousSnapshot = $currentSnapshot $previousSnapshot = $currentSnapshot
$previousTime = $currentTime $previousTime = $currentTime
} }
Write-Host "Sampling finished." Write-Host "Sampling finished: $outputFile"
+62
View File
@@ -0,0 +1,62 @@
#include "TestHarness.h"
#include <QCoreApplication>
#include <QStandardPaths>
#include <QTextStream>
#include <exception>
int main(int argc, char *argv[])
{
QStandardPaths::setTestModeEnabled(true);
QCoreApplication application(argc, argv);
QCoreApplication::setOrganizationName(QStringLiteral("QtDesktopPetInternalQA"));
QCoreApplication::setApplicationName(QStringLiteral("CoreLogicTests"));
QTextStream output(stdout);
QTextStream errors(stderr);
QString filter;
for (int index = 1; index < argc; ++index)
{
const QString argument = QString::fromLocal8Bit(argv[index]);
if (argument.startsWith(QStringLiteral("--filter=")))
{
filter = argument.mid(QStringLiteral("--filter=").size()).trimmed();
}
}
int failedCount = 0;
int executedCount = 0;
for (const Qa::TestCase &test : Qa::registry())
{
const QString testName = QString::fromStdString(test.name);
if (!filter.isEmpty() && !testName.contains(filter, Qt::CaseInsensitive))
{
continue;
}
++executedCount;
try
{
test.function();
output << "[PASS] " << testName << Qt::endl;
}
catch (const std::exception &exception)
{
++failedCount;
errors << "[FAIL] " << QString::fromStdString(test.name)
<< ": " << exception.what() << Qt::endl;
}
catch (...)
{
++failedCount;
errors << "[FAIL] " << QString::fromStdString(test.name)
<< ": unknown exception" << Qt::endl;
}
}
output << "Executed " << executedCount << " tests; "
<< failedCount << " failed." << Qt::endl;
return failedCount == 0 ? 0 : 1;
}
@@ -0,0 +1,53 @@
#include "TestHarness.h"
#include "src/ai/AIDiagnostics.h"
#include <QByteArray>
#include <QUrl>
QA_TEST(AIDiagnostics, RedactsCredentialsFromContextAndFreeText)
{
AIConfig config;
config.provider = QStringLiteral("custom");
config.protocol = QStringLiteral("openai-compatible");
config.model = QStringLiteral("test-model");
config.apiKey = QStringLiteral("sk-super-secret-value");
const QString context = AIDiagnostics::diagnosticContext(
config,
QUrl(QStringLiteral("https://example.test/v1/chat?api_key=query-secret&trace=visible")));
const QString freeText = AIDiagnostics::safeTextSummary(
QStringLiteral("Authorization: Bearer bearer-secret token=token-secret sk-another-secret-value"));
QA_EXPECT(!context.contains(QStringLiteral("sk-super-secret-value")));
QA_EXPECT(!context.contains(QStringLiteral("query-secret")));
QA_EXPECT(context.contains(QStringLiteral("trace=visible")));
QA_EXPECT(!freeText.contains(QStringLiteral("bearer-secret")));
QA_EXPECT(!freeText.contains(QStringLiteral("token-secret")));
QA_EXPECT(!freeText.contains(QStringLiteral("sk-another-secret-value")));
}
QA_TEST(AIDiagnostics, RedactsConversationAndCredentialFieldsFromJson)
{
const QByteArray body = QByteArrayLiteral(
R"({"safe":"visible","apiKey":"api-secret","authorization":"Bearer header-secret","messages":[{"content":"private prompt"}],"nested":{"token":"token-secret"}})");
const QString summary = AIDiagnostics::responseBodySummary(body);
QA_EXPECT(summary.contains(QStringLiteral("visible")));
QA_EXPECT(!summary.contains(QStringLiteral("api-secret")));
QA_EXPECT(!summary.contains(QStringLiteral("header-secret")));
QA_EXPECT(!summary.contains(QStringLiteral("private prompt")));
QA_EXPECT(!summary.contains(QStringLiteral("token-secret")));
}
QA_TEST(AIDiagnostics, KeepsUsefulErrorMetadataWithoutCredentialLeakage)
{
const QByteArray body = QByteArrayLiteral(
R"({"error":{"message":"API key sk-super-secret-value is invalid","code":"invalid_api_key","type":"authentication_error"},"content":"private response"})");
const QString error = AIDiagnostics::errorMessageFromBody(body);
QA_EXPECT(error.contains(QStringLiteral("invalid")));
QA_EXPECT(error.contains(QStringLiteral("invalid_api_key")));
QA_EXPECT(!error.contains(QStringLiteral("sk-super-secret-value")));
QA_EXPECT(!error.contains(QStringLiteral("private response")));
}
@@ -0,0 +1,118 @@
#include "TestHarness.h"
#include "src/ai/ConversationStore.h"
#include <QByteArray>
#include <QDate>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QTime>
namespace
{
ChatMessage message(const QString &role, const QString &content, int minute)
{
return {
role,
content,
QDateTime(QDate(2026, 7, 17), QTime(10, minute)),
QStringLiteral("openai"),
QStringLiteral("test-model"),
};
}
bool writeBytes(const QString &filePath, const QByteArray &bytes)
{
QFile file(filePath);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
&& file.write(bytes) == bytes.size();
}
}
QA_TEST(ConversationStore, SavesLoadsAndLimitsEvenHistory)
{
QTemporaryDir directory;
QA_EXPECT(directory.isValid());
const QString filePath = directory.filePath(QStringLiteral("conversation_history.json"));
const ConversationStore store(filePath);
const QVector<ChatMessage> messages = {
message(QStringLiteral("user"), QStringLiteral("one"), 0),
message(QStringLiteral("assistant"), QStringLiteral("two"), 1),
message(QStringLiteral("user"), QStringLiteral("three"), 2),
message(QStringLiteral("assistant"), QStringLiteral("four"), 3),
};
QA_EXPECT(store.save(messages, 4));
const QVector<ChatMessage> loaded = store.load(3);
QA_EXPECT_EQ(loaded.size(), 2);
QA_EXPECT_EQ(loaded.first().content, QStringLiteral("three"));
QA_EXPECT_EQ(loaded.last().model, QStringLiteral("test-model"));
}
QA_TEST(ConversationStore, AvoidsDuplicatingOverlappingMessages)
{
QTemporaryDir directory;
QA_EXPECT(directory.isValid());
const ConversationStore store(directory.filePath(QStringLiteral("history.json")));
const QVector<ChatMessage> firstPair = {
message(QStringLiteral("user"), QStringLiteral("hello"), 0),
message(QStringLiteral("assistant"), QStringLiteral("world"), 1),
};
QA_EXPECT(store.save(firstPair, 10));
QA_EXPECT(store.save(firstPair, 10));
QA_EXPECT_EQ(store.load(10).size(), 2);
}
QA_TEST(ConversationStore, LoadsLegacyCreatedAtField)
{
QTemporaryDir directory;
QA_EXPECT(directory.isValid());
const QString filePath = directory.filePath(QStringLiteral("legacy.json"));
QJsonArray messages;
messages.append(QJsonObject{
{QStringLiteral("role"), QStringLiteral("user")},
{QStringLiteral("content"), QStringLiteral("legacy-user")},
{QStringLiteral("createdAt"), QStringLiteral("2026-07-17T10:00:00")},
});
messages.append(QJsonObject{
{QStringLiteral("role"), QStringLiteral("assistant")},
{QStringLiteral("content"), QStringLiteral("legacy-assistant")},
{QStringLiteral("createdAt"), QStringLiteral("2026-07-17T10:00:01")},
});
QA_EXPECT(writeBytes(filePath, QJsonDocument(QJsonObject{{QStringLiteral("messages"), messages}}).toJson()));
const QVector<ChatMessage> loaded = ConversationStore(filePath).load(10);
QA_EXPECT_EQ(loaded.size(), 2);
QA_EXPECT(loaded.first().timestamp.isValid());
QA_EXPECT(loaded.first().provider.isEmpty());
}
QA_TEST(ConversationStore, BacksUpBrokenJsonAndClearsHistory)
{
QTemporaryDir directory;
QA_EXPECT(directory.isValid());
const QString filePath = directory.filePath(QStringLiteral("conversation_history.json"));
QA_EXPECT(writeBytes(filePath, QByteArrayLiteral("{broken")));
QString errorMessage;
const ConversationStore store(filePath);
QA_EXPECT(store.load(10, &errorMessage).isEmpty());
QA_EXPECT(!errorMessage.isEmpty());
QA_EXPECT(!QFile::exists(filePath));
QA_EXPECT_EQ(
QDir(directory.path()).entryList({QStringLiteral("conversation_history.broken.*.json")}, QDir::Files).size(),
1);
QA_EXPECT(store.save({
message(QStringLiteral("user"), QStringLiteral("hello"), 0),
message(QStringLiteral("assistant"), QStringLiteral("world"), 1),
}, 10));
QA_EXPECT(store.clear());
QA_EXPECT(!QFile::exists(filePath));
}
@@ -0,0 +1,42 @@
#include "TestHarness.h"
#include "src/assistant/CommandDispatcher.h"
#include "src/assistant/IntentRouter.h"
QA_TEST(IntentRouting, TrimsChatText)
{
const UserIntent intent = IntentRouter().route(QStringLiteral(" 你好 "));
QA_EXPECT_EQ(intent.type, UserIntentType::Chat);
QA_EXPECT_EQ(intent.text, QStringLiteral("你好"));
}
QA_TEST(IntentRouting, ReminderHasPriorityOverWeather)
{
const UserIntent intent = IntentRouter().route(QStringLiteral("明天提醒我看天气"));
QA_EXPECT_EQ(intent.type, UserIntentType::Reminder);
}
QA_TEST(IntentRouting, RecognizesWeatherExpressions)
{
const IntentRouter router;
QA_EXPECT_EQ(router.route(QStringLiteral("西安天气怎么样")).type, UserIntentType::Weather);
QA_EXPECT_EQ(router.route(QStringLiteral("明天会不会下雨")).type, UserIntentType::Weather);
QA_EXPECT_EQ(router.route(QStringLiteral("外面冷不冷")).type, UserIntentType::Weather);
}
QA_TEST(IntentRouting, RemovedLocalCapabilitiesRemainChat)
{
const IntentRouter router;
QA_EXPECT_EQ(router.route(QStringLiteral("读取文件")).type, UserIntentType::Chat);
QA_EXPECT_EQ(router.route(QStringLiteral("打开 Codex")).type, UserIntentType::Chat);
QA_EXPECT_EQ(router.route(QStringLiteral("分析这个项目")).type, UserIntentType::Chat);
QA_EXPECT_EQ(router.route(QStringLiteral("创建 test.txt")).type, UserIntentType::Chat);
}
QA_TEST(IntentRouting, DispatcherMapsSupportedActions)
{
const CommandDispatcher dispatcher;
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("提醒我休息")).action, CommandDispatchAction::Reminder);
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("北京天气")).action, CommandDispatchAction::Weather);
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("讲个笑话")).action, CommandDispatchAction::Chat);
}
@@ -0,0 +1,153 @@
#include "TestHarness.h"
#include "src/config/ConfigManager.h"
#include "src/reminder/ReminderStore.h"
#include "src/weather/WeatherStore.h"
#include "src/web/WebStore.h"
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QFileInfo>
namespace
{
bool writeBrokenJson(const QString &filePath)
{
QDir().mkpath(QFileInfo(filePath).absolutePath());
QFile file(filePath);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
&& file.write(QByteArrayLiteral("{broken")) == 7;
}
QString qaConfigDirectory()
{
return QFileInfo(ConfigManager().appConfigPath()).absolutePath();
}
bool isQaConfigDirectory(const QString &path)
{
return path.contains(QStringLiteral("QtDesktopPetInternalQA"), Qt::CaseInsensitive)
|| path.contains(QStringLiteral("qttest"), Qt::CaseInsensitive);
}
void clearQaConfigFiles()
{
const QString path = qaConfigDirectory();
if (!isQaConfigDirectory(path))
{
return;
}
QDir directory(path);
const QStringList filters = {
QStringLiteral("app_config.json"),
QStringLiteral("app_config.broken.*.json"),
QStringLiteral("ai_config.json"),
QStringLiteral("ai_config.broken.*.json"),
QStringLiteral("weather_config.json"),
QStringLiteral("weather_config.broken.*.json"),
QStringLiteral("web_config.json"),
QStringLiteral("web_config.broken.*.json"),
QStringLiteral("reminders.json"),
QStringLiteral("reminders.broken.*.json"),
};
for (const QString &fileName : directory.entryList(filters, QDir::Files))
{
directory.remove(fileName);
}
}
int backupCount(const QString &pattern)
{
return QDir(qaConfigDirectory()).entryList({pattern}, QDir::Files).size();
}
class QaConfigFiles
{
public:
QaConfigFiles()
{
QA_EXPECT(isQaConfigDirectory(qaConfigDirectory()));
clearQaConfigFiles();
}
~QaConfigFiles()
{
clearQaConfigFiles();
}
};
}
QA_TEST(ConfigRecovery, AppConfigFallsBackAndBacksUpBrokenJson)
{
QaConfigFiles files;
ConfigManager manager;
QA_EXPECT(writeBrokenJson(manager.appConfigPath()));
const AppConfig config = manager.loadAppConfig();
QA_EXPECT_EQ(config.windowPosition, QPoint(100, 100));
QA_EXPECT(config.alwaysOnTop);
QA_EXPECT(!QFile::exists(manager.appConfigPath()));
QA_EXPECT_EQ(backupCount(QStringLiteral("app_config.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, AiConfigFallsBackAndBacksUpBrokenJson)
{
QaConfigFiles files;
ConfigManager manager;
QA_EXPECT(writeBrokenJson(manager.aiConfigPath()));
const AIConfigStore store = manager.loadAIConfigStore();
QA_EXPECT_EQ(store.activeProvider, QStringLiteral("custom"));
QA_EXPECT(store.providers.isEmpty());
QA_EXPECT(!QFile::exists(manager.aiConfigPath()));
QA_EXPECT_EQ(backupCount(QStringLiteral("ai_config.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, WeatherAndWebConfigsFallBackAndBackUpBrokenJson)
{
QaConfigFiles files;
WeatherStore weatherStore;
WebStore webStore;
QA_EXPECT(writeBrokenJson(weatherStore.storePath()));
QA_EXPECT(writeBrokenJson(webStore.storePath()));
QString weatherError;
QString webError;
const WeatherConfig weather = weatherStore.load(&weatherError);
const WebConfig web = webStore.load(&webError);
QA_EXPECT_EQ(weather.provider, QStringLiteral("open-meteo"));
QA_EXPECT(weather.autoLocateWhenNoDefault);
QA_EXPECT(web.enabled);
QA_EXPECT(!web.defaultToggleOn);
QA_EXPECT(!weatherError.isEmpty());
QA_EXPECT(!webError.isEmpty());
QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 1);
QA_EXPECT_EQ(backupCount(QStringLiteral("web_config.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, ReminderStoreFallsBackToEmptyAndBacksUpBrokenJson)
{
QaConfigFiles files;
ReminderStore store;
QA_EXPECT(writeBrokenJson(store.storePath()));
QString error;
QA_EXPECT(store.load(&error).isEmpty());
QA_EXPECT(!error.isEmpty());
QA_EXPECT(!QFile::exists(store.storePath()));
QA_EXPECT_EQ(backupCount(QStringLiteral("reminders.broken.*.json")), 1);
}
QA_TEST(ConfigRecovery, SameSecondBackupsNeverOverwriteEarlierFiles)
{
QaConfigFiles files;
WeatherStore store;
QA_EXPECT(writeBrokenJson(store.storePath()));
store.load();
QA_EXPECT(writeBrokenJson(store.storePath()));
store.load();
QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 2);
}
@@ -0,0 +1,105 @@
#include "TestHarness.h"
#include "src/reminder/ReminderParser.h"
#include "src/reminder/ReminderTypes.h"
#include <QDate>
#include <QDateTime>
#include <QStringList>
#include <QTime>
namespace
{
QDateTime fixedNow()
{
return QDateTime(QDate(2026, 7, 17), QTime(14, 0));
}
}
QA_TEST(ReminderParser, ParsesRelativeMinutes)
{
const ReminderCommand command = ReminderParser().parse(QStringLiteral("10分钟后提醒我喝水"), fixedNow());
QA_EXPECT_EQ(command.type, ReminderCommandType::Create);
QA_EXPECT_EQ(command.remindAt, fixedNow().addSecs(10 * 60));
QA_EXPECT_EQ(command.title, QStringLiteral("喝水"));
}
QA_TEST(ReminderParser, ParsesHalfHours)
{
const ReminderParser parser;
QA_EXPECT_EQ(
parser.parse(QStringLiteral("半小时后提醒我休息"), fixedNow()).remindAt,
fixedNow().addSecs(30 * 60));
QA_EXPECT_EQ(
parser.parse(QStringLiteral("一个半小时后提醒我喝水"), fixedNow()).remindAt,
fixedNow().addSecs(90 * 60));
}
QA_TEST(ReminderParser, ParsesTomorrowClockTime)
{
const ReminderCommand command = ReminderParser().parse(QStringLiteral("明天9点提醒我开会"), fixedNow());
QA_EXPECT_EQ(command.type, ReminderCommandType::Create);
QA_EXPECT_EQ(command.remindAt, QDateTime(QDate(2026, 7, 18), QTime(9, 0)));
}
QA_TEST(ReminderParser, ParsesSupportedRecurrence)
{
const ReminderParser parser;
const ReminderCommand daily = parser.parse(QStringLiteral("每天9点提醒我打卡"), fixedNow());
const ReminderCommand weekly = parser.parse(QStringLiteral("每周一上午10点提醒我周会"), fixedNow());
const ReminderCommand monthly = parser.parse(QStringLiteral("每月31号9点提醒我交报告"), fixedNow());
QA_EXPECT_EQ(daily.recurrence.type, ReminderRecurrenceType::Daily);
QA_EXPECT_EQ(daily.remindAt, QDateTime(QDate(2026, 7, 18), QTime(9, 0)));
QA_EXPECT_EQ(weekly.recurrence.type, ReminderRecurrenceType::Weekly);
QA_EXPECT_EQ(weekly.recurrence.weekday, 1);
QA_EXPECT_EQ(monthly.recurrence.type, ReminderRecurrenceType::Monthly);
QA_EXPECT_EQ(monthly.recurrence.monthDay, 31);
QA_EXPECT_EQ(monthly.remindAt, QDateTime(QDate(2026, 7, 31), QTime(9, 0)));
}
QA_TEST(ReminderParser, RejectsUnsupportedRecurrence)
{
const ReminderParser parser;
const QStringList requests = {
QStringLiteral("工作日9点提醒我打卡"),
QStringLiteral("每两天提醒我喝水"),
QStringLiteral("农历初一提醒我"),
QStringLiteral("每月最后一天9点提醒我结算"),
};
for (const QString &request : requests)
{
const ReminderCommand command = parser.parse(request, fixedNow());
QA_EXPECT_EQ(command.type, ReminderCommandType::Invalid);
QA_EXPECT(!command.errorMessage.isEmpty());
}
}
QA_TEST(ReminderParser, ParsesListAndCancelCommands)
{
const ReminderParser parser;
QA_EXPECT_EQ(parser.parse(QStringLiteral("提醒列表"), fixedNow()).type, ReminderCommandType::List);
const ReminderCommand cancel = parser.parse(QStringLiteral("取消喝水提醒"), fixedNow());
QA_EXPECT_EQ(cancel.type, ReminderCommandType::Cancel);
QA_EXPECT_EQ(cancel.cancelQuery, QStringLiteral("喝水"));
}
QA_TEST(ReminderTypes, ConvertsAndSortsValues)
{
QA_EXPECT_EQ(reminderStatusFromString(QStringLiteral("TRIGGERED")), ReminderStatus::Triggered);
QA_EXPECT_EQ(reminderRecurrenceTypeFromString(QStringLiteral("weekly")), ReminderRecurrenceType::Weekly);
QA_EXPECT_EQ(reminderRecurrenceTypeToString(ReminderRecurrenceType::Monthly), QStringLiteral("monthly"));
ReminderItem later;
later.id = QStringLiteral("later");
later.remindAt = fixedNow().addSecs(60);
ReminderItem earlier;
earlier.id = QStringLiteral("earlier");
earlier.remindAt = fixedNow();
const QVector<ReminderItem> sorted = sortedReminders({later, earlier});
QA_EXPECT_EQ(sorted.first().id, QStringLiteral("earlier"));
QA_EXPECT_EQ(sorted.last().id, QStringLiteral("later"));
}
+225
View File
@@ -0,0 +1,225 @@
#include "TestHarness.h"
#include "src/weather/WeatherParser.h"
#include "src/weather/WeatherLocationResolver.h"
#include "src/weather/WeatherSummaryFormatter.h"
namespace
{
WeatherLocationCandidate candidate(
const QString &city,
const QString &admin,
const QString &country,
const QString &countryCode,
const QString &featureCode,
qint64 population,
double latitude,
double longitude)
{
WeatherLocationCandidate value;
value.cityName = city;
value.adminName = admin;
value.countryName = country;
value.countryCode = countryCode;
value.featureCode = featureCode;
value.population = population;
value.latitude = latitude;
value.longitude = longitude;
return value;
}
}
QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates)
{
const WeatherParser parser;
const WeatherQuery current = parser.parse(QStringLiteral("西安天气怎么样"));
const WeatherQuery tomorrow = parser.parse(QStringLiteral("明天西安天气怎么样"));
const WeatherQuery afterTomorrow = parser.parse(QStringLiteral("后天纽约天气"));
QA_EXPECT_EQ(current.cityName, QStringLiteral("西安"));
QA_EXPECT_EQ(current.kind, WeatherQueryKind::Current);
QA_EXPECT_EQ(tomorrow.cityName, QStringLiteral("西安"));
QA_EXPECT_EQ(tomorrow.dateOffset, 1);
QA_EXPECT_EQ(afterTomorrow.cityName, QStringLiteral("纽约"));
QA_EXPECT_EQ(afterTomorrow.dateOffset, 2);
}
QA_TEST(WeatherParser, ParsesRangeAndDefaultCityRequest)
{
const WeatherParser parser;
const WeatherQuery range = parser.parse(QStringLiteral("未来三天北京天气"));
const WeatherQuery defaultCity = parser.parse(QStringLiteral("今天天气怎么样"));
QA_EXPECT_EQ(range.kind, WeatherQueryKind::Range);
QA_EXPECT_EQ(range.forecastDays, 3);
QA_EXPECT_EQ(range.cityName, QStringLiteral("北京"));
QA_EXPECT(defaultCity.cityName.isEmpty());
}
QA_TEST(WeatherParser, RejectsUnsupportedQueries)
{
const WeatherParser parser;
QA_EXPECT(!parser.parse(QStringLiteral("西安空气质量")).unsupportedReason.isEmpty());
QA_EXPECT(!parser.parse(QStringLiteral("北京天气预警")).unsupportedReason.isEmpty());
QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty());
}
QA_TEST(WeatherLocationResolver, ResolvesChineseInternationalCities)
{
const WeatherLocationSearch newYork = WeatherLocationResolver::resolveSearch(QStringLiteral("纽约"));
const WeatherLocationSearch tokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京"));
const WeatherLocationSearch japanTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京"));
const WeatherLocationSearch qualifiedTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京,日本"));
QA_EXPECT_EQ(newYork.queryName, QStringLiteral("New York"));
QA_EXPECT_EQ(newYork.countryCode, QStringLiteral("US"));
QA_EXPECT_EQ(tokyo.queryName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(tokyo.countryCode, QStringLiteral("JP"));
QA_EXPECT_EQ(japanTokyo.queryName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(japanTokyo.countryCode, QStringLiteral("JP"));
QA_EXPECT_EQ(qualifiedTokyo.queryName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(qualifiedTokyo.countryCode, QStringLiteral("JP"));
}
QA_TEST(WeatherLocationResolver, PreservesDomesticCityAndCountryQualifier)
{
const WeatherLocationSearch xian = WeatherLocationResolver::resolveSearch(QStringLiteral("西安市"));
const WeatherLocationSearch chinaXian = WeatherLocationResolver::resolveSearch(QStringLiteral("中国西安"));
QA_EXPECT_EQ(xian.queryName, QStringLiteral("西安"));
QA_EXPECT(xian.countryCode.isEmpty());
QA_EXPECT_EQ(chinaXian.queryName, QStringLiteral("西安"));
QA_EXPECT_EQ(chinaXian.countryCode, QStringLiteral("CN"));
}
QA_TEST(WeatherLocationResolver, SuppressesLowValueAlternatives)
{
const QVector<WeatherLocationCandidate> candidates = {
candidate(QStringLiteral("西安"), QStringLiteral("陕西"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPLA"), 6500000, 34.26, 108.93),
candidate(QStringLiteral("西安"), QStringLiteral("湖南"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), 1200, 27.10, 111.20),
candidate(QStringLiteral("西安"), QStringLiteral("广东"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), -1, 23.10, 113.20),
};
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
candidates,
WeatherLocationResolver::resolveSearch(QStringLiteral("西安")));
QA_EXPECT_EQ(selected.size(), 1);
QA_EXPECT(!WeatherLocationResolver::hasAmbiguity(selected));
}
QA_TEST(WeatherLocationResolver, KeepsPlausibleAmbiguity)
{
const QVector<WeatherLocationCandidate> candidates = {
candidate(QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA2"), 170000, 37.21, -93.29),
candidate(QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA"), 114000, 39.80, -89.64),
candidate(QStringLiteral("Springfield"), QStringLiteral("Georgia"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 2700, 32.37, -81.31),
};
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
candidates,
WeatherLocationResolver::resolveSearch(QStringLiteral("Springfield")));
QA_EXPECT_EQ(selected.size(), 2);
QA_EXPECT(WeatherLocationResolver::hasAmbiguity(selected));
}
QA_TEST(WeatherLocationResolver, AppliesExplicitCountryFilter)
{
const QVector<WeatherLocationCandidate> candidates = {
candidate(QStringLiteral("Tokyo"), QStringLiteral("Tokyo"), QStringLiteral("Japan"), QStringLiteral("JP"), QStringLiteral("PPLC"), 8300000, 35.68, 139.69),
candidate(QStringLiteral("Tokyo"), QStringLiteral("Texas"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 1000, 33.90, -102.50),
};
const QVector<WeatherLocationCandidate> selected = WeatherLocationResolver::selectRelevantCandidates(
candidates,
WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京")));
QA_EXPECT_EQ(selected.size(), 1);
QA_EXPECT_EQ(selected.first().countryCode, QStringLiteral("JP"));
}
QA_TEST(WeatherLocationResolver, ParsesBothIpLocationSchemas)
{
const QByteArray ipApiPayload = QByteArrayLiteral(
R"({"city":"Xi'an","region":"Shaanxi","country_name":"China","latitude":34.26,"longitude":108.94,"timezone":"Asia/Shanghai"})");
const QByteArray ipWhoIsPayload = QByteArrayLiteral(
R"({"success":true,"city":"Tokyo","region":"Tokyo","country":"Japan","latitude":35.68,"longitude":139.69,"timezone":{"id":"Asia/Tokyo"}})");
WeatherLocation ipApiLocation;
WeatherLocation ipWhoIsLocation;
QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse(
ipApiPayload,
WeatherIpLocationProvider::IpApiCo,
&ipApiLocation));
QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse(
ipWhoIsPayload,
WeatherIpLocationProvider::IpWhoIs,
&ipWhoIsLocation));
QA_EXPECT_EQ(ipApiLocation.cityName, QStringLiteral("Xi'an"));
QA_EXPECT_EQ(ipApiLocation.countryName, QStringLiteral("China"));
QA_EXPECT_EQ(ipApiLocation.timezone, QStringLiteral("Asia/Shanghai"));
QA_EXPECT_EQ(ipWhoIsLocation.cityName, QStringLiteral("Tokyo"));
QA_EXPECT_EQ(ipWhoIsLocation.countryName, QStringLiteral("Japan"));
QA_EXPECT_EQ(ipWhoIsLocation.timezone, QStringLiteral("Asia/Tokyo"));
}
QA_TEST(WeatherLocationResolver, RejectsBrokenIpLocationResponses)
{
WeatherLocation location;
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
QByteArrayLiteral("{broken"),
WeatherIpLocationProvider::IpApiCo,
&location));
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
QByteArrayLiteral(R"({"success":false,"message":"rate limited"})"),
WeatherIpLocationProvider::IpWhoIs,
&location));
QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse(
QByteArrayLiteral(R"({"city":"Tokyo","latitude":35.68})"),
WeatherIpLocationProvider::IpWhoIs,
&location));
}
QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections)
{
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴"));
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(95), QStringLiteral("雷暴"));
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(-1), QStringLiteral("未知天气"));
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(0), QStringLiteral("北风"));
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(90), QStringLiteral("东风"));
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(225), QStringLiteral("西南风"));
}
QA_TEST(WeatherFormatter, FormatsCurrentWeatherWithSource)
{
WeatherReport report;
report.query.kind = WeatherQueryKind::Current;
report.location.cityName = QStringLiteral("西安");
report.location.countryName = QStringLiteral("中国");
report.location.source = WeatherLocationSource::SettingsDefault;
report.current.valid = true;
report.current.weatherCode = 0;
report.current.hasTemperature = true;
report.current.temperatureC = 28.5;
const QString message = WeatherSummaryFormatter().format(report);
QA_EXPECT(message.contains(QStringLiteral("使用设置页默认城市")));
QA_EXPECT(message.contains(QStringLiteral("西安,中国当前天气:晴")));
QA_EXPECT(message.contains(QStringLiteral("28.5℃")));
}
QA_TEST(WeatherFormatter, FormatsAmbiguousLocationAndMissingData)
{
WeatherReport report;
report.query.kind = WeatherQueryKind::Current;
report.location.cityName = QStringLiteral("Springfield");
report.location.countryName = QStringLiteral("United States");
report.hasLocationAmbiguity = true;
report.locationCandidates = {
{QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), {}, 0.0, 0.0},
{QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), {}, 0.0, 0.0},
};
const QString message = WeatherSummaryFormatter().format(report);
QA_EXPECT(message.contains(QStringLiteral("可能存在同名城市")));
QA_EXPECT(message.contains(QStringLiteral("Missouri")));
QA_EXPECT(message.contains(QStringLiteral("缺少当前天气")));
}
@@ -0,0 +1,65 @@
#include "TestHarness.h"
#include "src/config/AIConfig.h"
#include "src/web/WebCapabilityDetector.h"
namespace
{
AIConfig completeConfig(const QString &provider)
{
AIConfig config = defaultAIConfigForProvider(provider);
config.model = QStringLiteral("test-model");
config.apiKey = QStringLiteral("test-key-not-a-real-secret");
return config;
}
}
QA_TEST(WebCapability, RespectsGlobalDisable)
{
WebConfig webConfig;
webConfig.enabled = false;
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("openai")), webConfig);
QA_EXPECT(!capability.supported);
QA_EXPECT(capability.statusText.contains(QStringLiteral("已关闭")));
}
QA_TEST(WebCapability, AcceptsOfficialOpenAI)
{
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("openai")));
QA_EXPECT(capability.supported);
QA_EXPECT_EQ(capability.kind, WebProviderKind::OpenAIResponses);
}
QA_TEST(WebCapability, RejectsThirdPartyOpenAIBaseUrl)
{
AIConfig config = completeConfig(QStringLiteral("openai"));
config.baseUrl = QStringLiteral("https://relay.example.com/v1");
const WebCapability capability = WebCapabilityDetector::detect(config);
QA_EXPECT(!capability.supported);
QA_EXPECT(capability.userMessage.contains(QStringLiteral("api.openai.com")));
}
QA_TEST(WebCapability, AcceptsGeminiProtocol)
{
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("google")));
QA_EXPECT(capability.supported);
QA_EXPECT_EQ(capability.kind, WebProviderKind::GeminiGrounding);
}
QA_TEST(WebCapability, RejectsDeepSeekAndCustomProviders)
{
const WebCapability deepSeek = WebCapabilityDetector::detect(completeConfig(QStringLiteral("deepseek")));
const WebCapability custom = WebCapabilityDetector::detect(completeConfig(QStringLiteral("custom")));
QA_EXPECT(!deepSeek.supported);
QA_EXPECT(deepSeek.statusText.contains(QStringLiteral("DeepSeek")));
QA_EXPECT(!custom.supported);
QA_EXPECT(custom.statusText.contains(QStringLiteral("无法确认")));
}
QA_TEST(WebCapability, RequiresCompleteConfiguration)
{
AIConfig config = defaultAIConfigForProvider(QStringLiteral("openai"));
const WebCapability capability = WebCapabilityDetector::detect(config);
QA_EXPECT(!capability.supported);
QA_EXPECT(capability.statusText.contains(QStringLiteral("未配置完整")));
}
+3
View File
@@ -76,6 +76,9 @@ int main(int argc, char *argv[])
TrayController trayController(&window); TrayController trayController(&window);
window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable()); window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable());
window.setTrayNotificationCallback([&trayController](const QString &title, const QString &message) {
return trayController.showNotification(title, message);
});
trayController.show(); trayController.show();
QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() { QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() {
Binary file not shown.
Binary file not shown.
+21 -1
View File
@@ -5,14 +5,34 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError> #include <QJsonParseError>
#include <QJsonValue> #include <QJsonValue>
#include <QRegularExpression>
#include <QStringList> #include <QStringList>
#include <QUrlQuery> #include <QUrlQuery>
#include <QVector>
#include <QtGlobal> #include <QtGlobal>
namespace namespace
{ {
constexpr int MaxBodySummaryLength = 360; constexpr int MaxBodySummaryLength = 360;
QString redactSensitiveText(QString text)
{
static const QVector<QRegularExpression> credentialPatterns = {
QRegularExpression(QStringLiteral("\\bBearer\\s+[^\\s,;]+"), QRegularExpression::CaseInsensitiveOption),
QRegularExpression(QStringLiteral("\\bsk-[A-Za-z0-9_-]{8,}"), QRegularExpression::CaseInsensitiveOption),
QRegularExpression(QStringLiteral("\\bAIza[A-Za-z0-9_-]{20,}")),
QRegularExpression(
QStringLiteral("\\b(api[_-]?key|token|authorization|password|secret)\\s*[:=]\\s*[^\\s,;]+"),
QRegularExpression::CaseInsensitiveOption),
};
for (const QRegularExpression &pattern : credentialPatterns)
{
text.replace(pattern, QStringLiteral("<redacted>"));
}
return text;
}
bool isSensitiveName(const QString &name) bool isSensitiveName(const QString &name)
{ {
const QString lowerName = name.toLower(); const QString lowerName = name.toLower();
@@ -96,7 +116,7 @@ QString oneLine(QString text)
text.replace(QLatin1Char('\r'), QLatin1Char(' ')); text.replace(QLatin1Char('\r'), QLatin1Char(' '));
text.replace(QLatin1Char('\n'), QLatin1Char(' ')); text.replace(QLatin1Char('\n'), QLatin1Char(' '));
text.replace(QLatin1Char('\t'), QLatin1Char(' ')); text.replace(QLatin1Char('\t'), QLatin1Char(' '));
return text.simplified(); return redactSensitiveText(text.simplified());
} }
QString safeTextSummary(const QString &text, int maxLength) QString safeTextSummary(const QString &text, int maxLength)
+49 -36
View File
@@ -1,10 +1,33 @@
#include "ConversationManager.h" #include "ConversationManager.h"
#include <QDateTime>
#include <QtGlobal> #include <QtGlobal>
#include <utility> #include <utility>
ConversationManager::ConversationManager() ConversationManager::ConversationManager()
: m_systemPrompt(QStringLiteral("你是一个桌面宠物助手。回复要简短、自然,适合显示在桌宠气泡里。")) : m_systemPrompt(QStringLiteral(
"你是一个 Windows 桌面宠物助手,常驻在用户桌面上。回复要简短、自然、友好,适合显示在桌宠气泡里;除非用户明确要求详细解释,否则不要长篇输出。\n"
"\n"
"你可以帮助用户:\n"
"1. 普通聊天、解释问题、整理信息。\n"
"2. 创建和管理提醒:\n"
" - 例:“10分钟后提醒我喝水”“明天9点提醒我开会”“每天9点提醒我打卡”“每周一上午10点提醒我周会”“提醒列表”“取消喝水提醒”。\n"
" - 支持一次性提醒和每天/每周/每月重复提醒。\n"
"3. 查询天气:\n"
" - 例:“西安天气怎么样”“明天北京天气”“未来三天上海天气”。\n"
" - 建议用户使用市级城市名;区县、乡镇、街道不保证精确。\n"
"4. 联网模式:\n"
" - 用户需要打开输入框里的“联网”开关。\n"
" - 只有当前 AI 配置支持原生联网时才可用,例如 OpenAI 官方联网能力或 Gemini Google Search grounding。\n"
" - DeepSeek 官方 API、第三方 OpenAI-Compatible API 通常不具备可确认的原生联网能力;遇到这类配置时,应明确提醒用户当前配置无法联网,不要假装已经搜索。\n"
"\n"
"回答规则:\n"
"- 如果用户只是闲聊,直接自然回复。\n"
"- 如果用户问“你能做什么”,用简短列表说明上述功能和示例。\n"
"- 如果用户要创建提醒或查询天气,可以直接提示用户按示例输入。\n"
"- 当前应用不能读取、搜索、创建、修改或删除本地文件,不能启动本地应用,也不能执行命令或脚本;遇到这类请求要明确说明能力边界。\n"
"- 如果用户询问最新信息但没有开启联网,提醒用户打开“联网”开关;如果当前 AI 配置不支持联网,说明无法使用联网模式。\n"
"- 不要声称已经访问本地文件、启动应用或执行系统操作,也不要编造任何未发生的操作结果。"))
{ {
} }
@@ -66,6 +89,12 @@ void ConversationManager::setMemoryHistoryMessageLimit(int maxMessages)
pruneHistory(); pruneHistory();
} }
void ConversationManager::setConversationMetadata(const QString &provider, const QString &model)
{
m_currentProvider = provider.trimmed();
m_currentModel = model.trimmed();
}
bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider) bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
{ {
if (isBusy()) if (isBusy())
@@ -77,48 +106,30 @@ bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
return true; return true;
} }
void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback) ChatRequest ConversationManager::buildRequestForUserMessage(const QString &message) const
{ {
const QString content = message.trimmed(); const QString content = message.trimmed();
if (content.isEmpty()) if (content.isEmpty())
{ {
if (callback) return {};
{
callback({false, {}, QStringLiteral("Message is empty."), 0});
}
return;
} }
if (!m_provider) const ChatMessage userMessage{QStringLiteral("user"), content, QDateTime::currentDateTime(), m_currentProvider, m_currentModel};
return buildRequest(userMessage);
}
void ConversationManager::appendExternalExchange(const QString &userMessage, const QString &assistantMessage)
{
const QString userContent = userMessage.trimmed();
const QString assistantContent = assistantMessage.trimmed();
if (userContent.isEmpty() || assistantContent.isEmpty())
{ {
if (callback)
{
callback({false, {}, QStringLiteral("AI provider is not ready."), 0});
}
return; return;
} }
if (isBusy()) const ChatMessage user{QStringLiteral("user"), userContent, QDateTime::currentDateTime(), m_currentProvider, m_currentModel};
{ const ChatMessage assistant{QStringLiteral("assistant"), assistantContent, QDateTime::currentDateTime(), m_currentProvider, m_currentModel};
if (callback) appendExchange(user, assistant);
{
callback({false, {}, QStringLiteral("AI request is already running."), 0});
}
return;
}
const ChatMessage userMessage{QStringLiteral("user"), content};
m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
if (response.success)
{
appendExchange(userMessage, {QStringLiteral("assistant"), response.content});
}
if (callback)
{
callback(response);
}
});
} }
void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback) void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback)
@@ -151,14 +162,16 @@ void ConversationManager::sendUserMessageStreaming(const QString &message, Strea
return; return;
} }
const ChatMessage userMessage{QStringLiteral("user"), content}; const QDateTime timestamp = QDateTime::currentDateTime();
const ChatMessage userMessage{QStringLiteral("user"), content, timestamp, m_currentProvider, m_currentModel};
m_provider->sendStreamingChatRequest( m_provider->sendStreamingChatRequest(
buildRequest(userMessage), buildRequest(userMessage),
std::move(streamCallback), std::move(streamCallback),
[this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable { [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
if (response.success) if (response.success)
{ {
appendExchange(userMessage, {QStringLiteral("assistant"), response.content}); ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model};
appendExchange(userMessage, assistantMessage);
} }
if (callback) if (callback)
@@ -192,7 +205,7 @@ ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) co
ChatRequest request; ChatRequest request;
if (!m_systemPrompt.trimmed().isEmpty()) if (!m_systemPrompt.trimmed().isEmpty())
{ {
request.messages.append({QStringLiteral("system"), m_systemPrompt}); request.messages.append(ChatMessage{QStringLiteral("system"), m_systemPrompt});
} }
const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestContextMessages); const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestContextMessages);
+5 -1
View File
@@ -23,8 +23,10 @@ public:
void setHistory(const QVector<ChatMessage> &history); void setHistory(const QVector<ChatMessage> &history);
void setRequestContextMessageLimit(int maxMessages); void setRequestContextMessageLimit(int maxMessages);
void setMemoryHistoryMessageLimit(int maxMessages); void setMemoryHistoryMessageLimit(int maxMessages);
void setConversationMetadata(const QString &provider, const QString &model);
bool setProvider(std::unique_ptr<LLMProvider> provider); bool setProvider(std::unique_ptr<LLMProvider> provider);
void sendUserMessage(const QString &message, ResponseCallback callback); ChatRequest buildRequestForUserMessage(const QString &message) const;
void appendExternalExchange(const QString &userMessage, const QString &assistantMessage);
void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback); void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
void cancel(); void cancel();
void clear(); void clear();
@@ -38,6 +40,8 @@ private:
std::unique_ptr<LLMProvider> m_provider; std::unique_ptr<LLMProvider> m_provider;
QVector<ChatMessage> m_history; QVector<ChatMessage> m_history;
QString m_systemPrompt; QString m_systemPrompt;
QString m_currentProvider;
QString m_currentModel;
int m_maxRequestContextMessages = 12; int m_maxRequestContextMessages = 12;
int m_maxStoredHistoryMessages = 200; int m_maxStoredHistoryMessages = 200;
int m_prunedHistoryMessageCount = 0; int m_prunedHistoryMessageCount = 0;
+26 -1
View File
@@ -24,14 +24,39 @@ QJsonObject objectFromMessage(const ChatMessage &message)
QJsonObject object; QJsonObject object;
object.insert(QStringLiteral("role"), message.role); object.insert(QStringLiteral("role"), message.role);
object.insert(QStringLiteral("content"), message.content); object.insert(QStringLiteral("content"), message.content);
if (message.timestamp.isValid())
{
object.insert(QStringLiteral("timestamp"), message.timestamp.toString(Qt::ISODate));
}
if (!message.provider.trimmed().isEmpty())
{
object.insert(QStringLiteral("provider"), message.provider.trimmed());
}
if (!message.model.trimmed().isEmpty())
{
object.insert(QStringLiteral("model"), message.model.trimmed());
}
return object; return object;
} }
ChatMessage messageFromObject(const QJsonObject &object) ChatMessage messageFromObject(const QJsonObject &object)
{ {
QDateTime timestamp = QDateTime::fromString(
object.value(QStringLiteral("timestamp")).toString(),
Qt::ISODate);
if (!timestamp.isValid())
{
timestamp = QDateTime::fromString(
object.value(QStringLiteral("createdAt")).toString(),
Qt::ISODate);
}
return { return {
object.value(QStringLiteral("role")).toString().trimmed(), object.value(QStringLiteral("role")).toString().trimmed(),
object.value(QStringLiteral("content")).toString() object.value(QStringLiteral("content")).toString(),
timestamp,
object.value(QStringLiteral("provider")).toString().trimmed(),
object.value(QStringLiteral("model")).toString().trimmed()
}; };
} }
+4
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QDateTime>
#include <QString> #include <QString>
#include <QVector> #include <QVector>
@@ -7,6 +8,9 @@ struct ChatMessage
{ {
QString role; QString role;
QString content; QString content;
QDateTime timestamp;
QString provider;
QString model;
}; };
struct ChatRequest struct ChatRequest
+12 -1
View File
@@ -361,14 +361,25 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
return {false, {}, QStringLiteral("AI response has no choices."), httpStatus}; return {false, {}, QStringLiteral("AI response has no choices."), httpStatus};
} }
const QJsonObject message = choices.first().toObject().value(QStringLiteral("message")).toObject(); const QJsonObject choice = choices.first().toObject();
const QJsonObject message = choice.value(QStringLiteral("message")).toObject();
const QString content = message.value(QStringLiteral("content")).toString(); const QString content = message.value(QStringLiteral("content")).toString();
if (content.isEmpty()) if (content.isEmpty())
{ {
const QString finishReason = choice.value(QStringLiteral("finish_reason")).toString().trimmed();
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"") Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus) .arg(httpStatus)
.arg(AIDiagnostics::responseBodySummary(body))); .arg(AIDiagnostics::responseBodySummary(body)));
if (finishReason == QStringLiteral("length"))
{
return {
false,
{},
QStringLiteral("AI response reached the token limit before producing content. Increase Max Tokens."),
httpStatus,
};
}
return {false, {}, QStringLiteral("AI response content is empty."), httpStatus}; return {false, {}, QStringLiteral("AI response content is empty."), httpStatus};
} }
+32
View File
@@ -0,0 +1,32 @@
#include "CommandDispatcher.h"
QString userIntentTypeName(UserIntentType type)
{
switch (type)
{
case UserIntentType::Chat:
return QStringLiteral("Chat");
case UserIntentType::Reminder:
return QStringLiteral("Reminder");
case UserIntentType::Weather:
return QStringLiteral("Weather");
}
return QStringLiteral("Unknown");
}
CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
{
const UserIntent intent = m_intentRouter.route(text);
switch (intent.type)
{
case UserIntentType::Chat:
return {CommandDispatchAction::Chat, intent, intent.text};
case UserIntentType::Reminder:
return {CommandDispatchAction::Reminder, intent, intent.text};
case UserIntentType::Weather:
return {CommandDispatchAction::Weather, intent, intent.text};
}
return {CommandDispatchAction::Chat, intent, intent.text};
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "IntentRouter.h"
#include <QString>
enum class CommandDispatchAction
{
Chat,
Reminder,
Weather,
};
struct CommandDispatchResult
{
CommandDispatchAction action = CommandDispatchAction::Chat;
UserIntent intent;
QString message;
};
class CommandDispatcher
{
public:
CommandDispatchResult dispatch(const QString &text) const;
private:
IntentRouter m_intentRouter;
};
+67
View File
@@ -0,0 +1,67 @@
#include "IntentRouter.h"
#include <QStringList>
namespace
{
bool containsAny(const QString &text, const QStringList &keywords)
{
for (const QString &keyword : keywords)
{
if (text.contains(keyword, Qt::CaseInsensitive))
{
return true;
}
}
return false;
}
bool isReminderIntent(const QString &text)
{
static const QStringList keywords = {
QStringLiteral("提醒"),
QStringLiteral("提醒我"),
QStringLiteral("叫我"),
QStringLiteral("闹钟"),
QStringLiteral("到点"),
};
return containsAny(text, keywords);
}
bool isWeatherIntent(const QString &text)
{
static const QStringList keywords = {
QStringLiteral("天气"),
QStringLiteral("气温"),
QStringLiteral("温度"),
QStringLiteral("冷不冷"),
QStringLiteral("热不热"),
QStringLiteral("下雨"),
QStringLiteral("降雨"),
QStringLiteral("带伞"),
QStringLiteral("刮风"),
QStringLiteral("风力"),
QStringLiteral("湿度"),
QStringLiteral("空气质量"),
QStringLiteral("雾霾"),
QStringLiteral("适合出门"),
QStringLiteral("穿什么"),
QStringLiteral("外面怎么样"),
};
return containsAny(text, keywords);
}
}
UserIntent IntentRouter::route(const QString &text) const
{
const QString trimmedText = text.trimmed();
if (isReminderIntent(trimmedText))
{
return {UserIntentType::Reminder, trimmedText};
}
if (isWeatherIntent(trimmedText))
{
return {UserIntentType::Weather, trimmedText};
}
return {UserIntentType::Chat, trimmedText};
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "UserIntent.h"
#include <QString>
class IntentRouter
{
public:
UserIntent route(const QString &text) const;
};
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <QString>
enum class UserIntentType
{
Chat,
Reminder,
Weather,
};
struct UserIntent
{
UserIntentType type = UserIntentType::Chat;
QString text;
};
QString userIntentTypeName(UserIntentType type);
+65 -12
View File
@@ -14,6 +14,7 @@
#include <QJsonValue> #include <QJsonValue>
#include <QSet> #include <QSet>
#include <QStandardPaths> #include <QStandardPaths>
#include <QStringList>
namespace namespace
{ {
@@ -535,18 +536,6 @@ QVector<CharacterPackageInfo> CharacterPackageRepository::availablePackages()
return packages; return packages;
} }
QStringList CharacterPackageRepository::availablePackageIds()
{
QStringList packageIds;
const QVector<CharacterPackageInfo> packages = availablePackages();
for (const CharacterPackageInfo &package : packages)
{
packageIds.append(package.id);
}
return packageIds;
}
CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath) CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath)
{ {
CharacterPackageValidationResult result; CharacterPackageValidationResult result;
@@ -629,6 +618,70 @@ bool CharacterPackageRepository::importPackageDirectory(
return true; return true;
} }
bool CharacterPackageRepository::exportPackageDirectory(
const QString &characterId,
const QString &targetParentDirectoryPath,
bool overwrite,
QString *exportedPath,
QString *errorMessage)
{
const QString trimmed = characterId.trimmed();
if (!isValidCharacterId(trimmed))
{
return setError(errorMessage, QStringLiteral("角色 id 无效。"));
}
const QString sourcePath = packagePath(trimmed);
if (sourcePath.isEmpty() || !QFileInfo::exists(QDir(sourcePath).filePath(QStringLiteral("character.json"))))
{
return setError(errorMessage, QStringLiteral("角色包不存在或不可导出。"));
}
QString validationId;
QString displayName;
if (!validatePackageDirectory(sourcePath, &validationId, &displayName, errorMessage))
{
return false;
}
const QFileInfo targetParentInfo(QDir::cleanPath(targetParentDirectoryPath));
if (!targetParentInfo.exists() || !targetParentInfo.isDir())
{
return setError(errorMessage, QStringLiteral("导出目标目录不存在。"));
}
const QString targetPath = QDir(targetParentInfo.absoluteFilePath()).filePath(trimmed);
const QString cleanedSourcePath = QDir::cleanPath(sourcePath);
const QString cleanedTargetPath = QDir::cleanPath(targetPath);
if (cleanedSourcePath == cleanedTargetPath)
{
return setError(errorMessage, QStringLiteral("导出目标不能与源角色目录相同。"));
}
QDir targetRoot(targetParentInfo.absoluteFilePath());
const QString exportStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz"));
const QString preparedPath = targetRoot.filePath(QStringLiteral(".exporting-") + trimmed + QStringLiteral("-") + exportStamp);
QDir(preparedPath).removeRecursively();
if (!copyDirectoryRecursively(cleanedSourcePath, preparedPath, errorMessage))
{
QDir(preparedPath).removeRecursively();
return false;
}
if (!replaceDirectoryWithPreparedImport(preparedPath, cleanedTargetPath, overwrite, errorMessage))
{
QDir(preparedPath).removeRecursively();
return false;
}
if (exportedPath != nullptr)
{
*exportedPath = cleanedTargetPath;
}
return true;
}
bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage) bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage)
{ {
const QString trimmed = characterId.trimmed(); const QString trimmed = characterId.trimmed();
+6 -2
View File
@@ -1,7 +1,6 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QStringList>
#include <QVector> #include <QVector>
struct CharacterPackageInfo struct CharacterPackageInfo
@@ -40,7 +39,6 @@ public:
static bool hasBuiltInPackage(const QString &characterId); static bool hasBuiltInPackage(const QString &characterId);
static bool hasUserPackage(const QString &characterId); static bool hasUserPackage(const QString &characterId);
static QVector<CharacterPackageInfo> availablePackages(); static QVector<CharacterPackageInfo> availablePackages();
static QStringList availablePackageIds();
static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath); static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath);
static bool validatePackageDirectory( static bool validatePackageDirectory(
const QString &directoryPath, const QString &directoryPath,
@@ -52,5 +50,11 @@ public:
bool overwrite, bool overwrite,
QString *importedCharacterId = nullptr, QString *importedCharacterId = nullptr,
QString *errorMessage = nullptr); QString *errorMessage = nullptr);
static bool exportPackageDirectory(
const QString &characterId,
const QString &targetParentDirectoryPath,
bool overwrite,
QString *exportedPath = nullptr,
QString *errorMessage = nullptr);
static bool deleteUserPackage(const QString &characterId, QString *errorMessage = nullptr); static bool deleteUserPackage(const QString &characterId, QString *errorMessage = nullptr);
}; };
-5
View File
@@ -79,11 +79,6 @@ bool FrameAnimator::isPlaying() const
return m_timer.isActive() && m_clip != nullptr; return m_timer.isActive() && m_clip != nullptr;
} }
bool FrameAnimator::isPaused() const
{
return m_paused && m_clip != nullptr;
}
QString FrameAnimator::currentStateName() const QString FrameAnimator::currentStateName() const
{ {
if (m_clip == nullptr) if (m_clip == nullptr)
-1
View File
@@ -22,7 +22,6 @@ public:
void resume(); void resume();
void stop(); void stop();
bool isPlaying() const; bool isPlaying() const;
bool isPaused() const;
QString currentStateName() const; QString currentStateName() const;
private: private:
+4
View File
@@ -8,6 +8,7 @@ struct AppConfig
QPoint windowPosition = QPoint(100, 100); QPoint windowPosition = QPoint(100, 100);
bool hasWindowPosition = false; bool hasWindowPosition = false;
bool alwaysOnTop = true; bool alwaysOnTop = true;
bool launchAtStartup = false;
double scale = 1.0; double scale = 1.0;
QString performanceMode = QStringLiteral("standard"); QString performanceMode = QStringLiteral("standard");
bool pauseWhenHidden = true; bool pauseWhenHidden = true;
@@ -16,6 +17,9 @@ struct AppConfig
int animationCacheLimitMb = 180; int animationCacheLimitMb = 180;
bool unloadAnimationsWhenHidden = true; bool unloadAnimationsWhenHidden = true;
QString characterId = QStringLiteral("shiroko"); QString characterId = QStringLiteral("shiroko");
QString reminderSoundId = QStringLiteral("reminder_default");
bool reminderSoundEnabled = true;
double reminderSoundVolume = 0.8;
int requestContextMessageLimit = 12; int requestContextMessageLimit = 12;
int memoryHistoryMessageLimit = 200; int memoryHistoryMessageLimit = 200;
bool saveConversationHistory = false; bool saveConversationHistory = false;
+45 -15
View File
@@ -11,6 +11,7 @@
#include <QJsonParseError> #include <QJsonParseError>
#include <QJsonValue> #include <QJsonValue>
#include <QStandardPaths> #include <QStandardPaths>
#include <QtGlobal>
namespace namespace
{ {
@@ -28,6 +29,13 @@ QJsonObject windowObjectFromConfig(const AppConfig &config)
return window; return window;
} }
QJsonObject systemObjectFromConfig(const AppConfig &config)
{
QJsonObject system;
system.insert(QStringLiteral("launchAtStartup"), config.launchAtStartup);
return system;
}
QJsonObject performanceObjectFromConfig(const AppConfig &config) QJsonObject performanceObjectFromConfig(const AppConfig &config)
{ {
QJsonObject performance; QJsonObject performance;
@@ -57,6 +65,15 @@ QJsonObject characterObjectFromConfig(const AppConfig &config)
return character; return character;
} }
QJsonObject reminderObjectFromConfig(const AppConfig &config)
{
QJsonObject reminder;
reminder.insert(QStringLiteral("soundId"), config.reminderSoundId);
reminder.insert(QStringLiteral("soundEnabled"), config.reminderSoundEnabled);
reminder.insert(QStringLiteral("soundVolume"), qBound(0.0, config.reminderSoundVolume, 1.0));
return reminder;
}
QString normalizedProviderName(const QString &provider) QString normalizedProviderName(const QString &provider)
{ {
const QString normalized = provider.trimmed().toLower(); const QString normalized = provider.trimmed().toLower();
@@ -200,6 +217,12 @@ AppConfig ConfigManager::loadAppConfig() const
config.scale = window.value(QStringLiteral("scale")).toDouble(config.scale); config.scale = window.value(QStringLiteral("scale")).toDouble(config.scale);
} }
const QJsonObject system = root.value(QStringLiteral("system")).toObject();
if (system.contains(QStringLiteral("launchAtStartup")))
{
config.launchAtStartup = system.value(QStringLiteral("launchAtStartup")).toBool(config.launchAtStartup);
}
const QJsonObject performance = root.value(QStringLiteral("performance")).toObject(); const QJsonObject performance = root.value(QStringLiteral("performance")).toObject();
if (performance.contains(QStringLiteral("mode"))) if (performance.contains(QStringLiteral("mode")))
{ {
@@ -258,6 +281,26 @@ AppConfig ConfigManager::loadAppConfig() const
config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed(); config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed();
} }
const QJsonObject reminder = root.value(QStringLiteral("reminder")).toObject();
if (reminder.contains(QStringLiteral("soundId")))
{
config.reminderSoundId = reminder.value(QStringLiteral("soundId")).toString(config.reminderSoundId).trimmed();
if (config.reminderSoundId.isEmpty())
{
config.reminderSoundId = QStringLiteral("reminder_default");
}
}
if (reminder.contains(QStringLiteral("soundEnabled")))
{
config.reminderSoundEnabled = reminder.value(QStringLiteral("soundEnabled")).toBool(config.reminderSoundEnabled);
}
if (reminder.contains(QStringLiteral("soundVolume")))
{
config.reminderSoundVolume = qBound(0.0, reminder.value(QStringLiteral("soundVolume")).toDouble(config.reminderSoundVolume), 1.0);
}
return config; return config;
} }
@@ -343,9 +386,11 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
QJsonObject root; QJsonObject root;
root.insert(QStringLiteral("window"), windowObjectFromConfig(config)); root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
root.insert(QStringLiteral("system"), systemObjectFromConfig(config));
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config)); root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
root.insert(QStringLiteral("character"), characterObjectFromConfig(config)); root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config));
QFile file(appConfigPath()); QFile file(appConfigPath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
@@ -379,21 +424,6 @@ bool ConfigManager::saveAIConfigStore(const AIConfigStore &store) const
return file.write(document.toJson(QJsonDocument::Indented)) >= 0; return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
} }
bool ConfigManager::saveAIConfig(const AIConfig &config) const
{
AIConfig normalizedConfig = config;
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
if (isRemovedProviderName(normalizedConfig.provider))
{
normalizedConfig = defaultAIConfigForProvider(QStringLiteral("custom"));
}
AIConfigStore store = loadAIConfigStore();
store.activeProvider = normalizedConfig.provider;
store.providers.insert(normalizedConfig.provider, normalizedConfig);
return saveAIConfigStore(store);
}
QString ConfigManager::appConfigPath() const QString ConfigManager::appConfigPath() const
{ {
return QDir(configDirectoryPath()).filePath(AppConfigFileName); return QDir(configDirectoryPath()).filePath(AppConfigFileName);
-1
View File
@@ -15,7 +15,6 @@ public:
AIConfig loadAIConfig() const; AIConfig loadAIConfig() const;
bool saveAppConfig(const AppConfig &config) const; bool saveAppConfig(const AppConfig &config) const;
bool saveAIConfigStore(const AIConfigStore &store) const; bool saveAIConfigStore(const AIConfigStore &store) const;
bool saveAIConfig(const AIConfig &config) const;
QString appConfigPath() const; QString appConfigPath() const;
QString aiConfigPath() const; QString aiConfigPath() const;
QString conversationHistoryPath() const; QString conversationHistoryPath() const;
@@ -0,0 +1,18 @@
#include "NotificationDispatcher.h"
#include <utility>
void NotificationDispatcher::setShowCallback(ShowCallback callback)
{
m_showCallback = std::move(callback);
}
bool NotificationDispatcher::showReminder(const QString &title, const QString &message) const
{
if (m_showCallback)
{
return m_showCallback(title, message);
}
return false;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QString>
#include <functional>
class NotificationDispatcher
{
public:
using ShowCallback = std::function<bool(const QString &, const QString &)>;
void setShowCallback(ShowCallback callback);
bool showReminder(const QString &title, const QString &message) const;
private:
ShowCallback m_showCallback;
};
+57
View File
@@ -0,0 +1,57 @@
#include "ReminderCommandHandler.h"
#include "ReminderManager.h"
ReminderCommandResult ReminderCommandHandler::handle(const QString &text, ReminderManager &manager)
{
const ReminderCommand command = manager.parseCommand(text);
switch (command.type)
{
case ReminderCommandType::Create:
{
ReminderItem item;
QString errorMessage;
if (!manager.createReminder(command.title, command.originalText, command.remindAt, command.recurrence, &item, &errorMessage))
{
return {false, errorMessage.isEmpty() ? QStringLiteral("创建提醒失败。") : errorMessage, {}};
}
if (reminderIsRecurring(item))
{
return {
true,
QStringLiteral("已设置重复提醒:%1,规则:%2,下一次:%3")
.arg(item.title, reminderRecurrenceDisplayText(item), reminderDisplayTime(item.remindAt)),
item,
};
}
return {
true,
QStringLiteral("已设置提醒:%1,时间:%2").arg(item.title, reminderDisplayTime(item.remindAt)),
item,
};
}
case ReminderCommandType::List:
return {true, manager.pendingReminderSummary(), {}};
case ReminderCommandType::Cancel:
{
ReminderItem item;
QString errorMessage;
if (!manager.cancelReminderByQuery(command.cancelQuery, &item, &errorMessage))
{
return {false, errorMessage.isEmpty() ? QStringLiteral("取消提醒失败。") : errorMessage, {}};
}
return {
true,
QStringLiteral("已取消提醒:%1(%2)").arg(item.title, reminderDisplayTime(item.remindAt)),
item,
};
}
case ReminderCommandType::Invalid:
return {false, command.errorMessage.isEmpty() ? QStringLiteral("没有识别到有效提醒命令。") : command.errorMessage, {}};
}
return {false, QStringLiteral("没有识别到有效提醒命令。"), {}};
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "ReminderTypes.h"
#include <QString>
class ReminderManager;
struct ReminderCommandResult
{
bool success = false;
QString message;
ReminderItem item;
};
class ReminderCommandHandler
{
public:
static ReminderCommandResult handle(const QString &text, ReminderManager &manager);
};
+678
View File
@@ -0,0 +1,678 @@
#include "ReminderManager.h"
#include "../util/Logger.h"
#include <QDate>
#include <QDateTime>
#include <QRandomGenerator>
#include <QStringList>
#include <QTime>
#include <QtGlobal>
#include <utility>
namespace
{
constexpr qint64 MinimumTimerIntervalMs = 1000;
constexpr qint64 MaximumTimerIntervalMs = 24 * 60 * 60 * 1000;
constexpr int GuardTimerIntervalMs = 60 * 1000;
constexpr int DefaultHistoryRetentionDays = 20;
bool isPending(const ReminderItem &item)
{
return item.status == ReminderStatus::Pending;
}
bool isFinished(const ReminderItem &item)
{
return item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled;
}
QDateTime finishedReferenceTime(const ReminderItem &item)
{
return item.finishedAt.isValid() ? item.finishedAt : item.remindAt;
}
bool textMatchesReminder(const ReminderItem &item, const QString &query)
{
const QString normalizedQuery = query.trimmed();
if (normalizedQuery.isEmpty())
{
return false;
}
return item.id.compare(normalizedQuery, Qt::CaseInsensitive) == 0
|| item.title.contains(normalizedQuery, Qt::CaseInsensitive)
|| item.originalText.contains(normalizedQuery, Qt::CaseInsensitive);
}
QTime recurrenceTime(const ReminderItem &item)
{
const int hour = item.recurrence.hour >= 0 ? item.recurrence.hour : item.remindAt.time().hour();
const int minute = item.recurrence.minute >= 0 ? item.recurrence.minute : item.remindAt.time().minute();
if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
{
return {};
}
return QTime(hour, minute);
}
QDateTime nextDailyOccurrence(const ReminderItem &item, const QDateTime &after)
{
const QTime time = recurrenceTime(item);
if (!time.isValid())
{
return {};
}
const int interval = qMax(1, item.recurrence.interval);
QDateTime next(after.date(), time);
while (next <= after)
{
next = next.addDays(interval);
}
return next;
}
QDateTime nextWeeklyOccurrence(const ReminderItem &item, const QDateTime &after)
{
const QTime time = recurrenceTime(item);
if (!time.isValid() || item.recurrence.weekday < 1 || item.recurrence.weekday > 7)
{
return {};
}
const int intervalWeeks = qMax(1, item.recurrence.interval);
int daysToAdd = item.recurrence.weekday - after.date().dayOfWeek();
QDateTime next(after.date().addDays(daysToAdd), time);
while (daysToAdd < 0 || next <= after)
{
next = next.addDays(7 * intervalWeeks);
daysToAdd += 7 * intervalWeeks;
}
return next;
}
QDateTime nextMonthlyOccurrence(const ReminderItem &item, const QDateTime &after)
{
const QTime time = recurrenceTime(item);
if (!time.isValid() || item.recurrence.monthDay < 1 || item.recurrence.monthDay > 31)
{
return {};
}
const int intervalMonths = qMax(1, item.recurrence.interval);
QDate monthCursor(after.date().year(), after.date().month(), 1);
for (int attempt = 0; attempt < 240; ++attempt)
{
const QDate date(monthCursor.year(), monthCursor.month(), item.recurrence.monthDay);
if (date.isValid())
{
const QDateTime next(date, time);
if (next > after)
{
return next;
}
}
monthCursor = monthCursor.addMonths(intervalMonths);
}
return {};
}
QDateTime nextRecurringOccurrence(const ReminderItem &item, const QDateTime &after)
{
switch (item.recurrence.type)
{
case ReminderRecurrenceType::Daily:
return nextDailyOccurrence(item, after);
case ReminderRecurrenceType::Weekly:
return nextWeeklyOccurrence(item, after);
case ReminderRecurrenceType::Monthly:
return nextMonthlyOccurrence(item, after);
case ReminderRecurrenceType::None:
return {};
}
return {};
}
bool normalizeRecurrence(ReminderRecurrence *recurrence, QString *errorMessage)
{
if (recurrence == nullptr || recurrence->type == ReminderRecurrenceType::None)
{
if (recurrence != nullptr)
{
*recurrence = {};
}
return true;
}
recurrence->interval = qMax(1, recurrence->interval);
if (recurrence->hour < 0
|| recurrence->hour > 23
|| recurrence->minute < 0
|| recurrence->minute > 59)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("重复提醒时间无效。");
}
return false;
}
switch (recurrence->type)
{
case ReminderRecurrenceType::Daily:
recurrence->weekday = 0;
recurrence->monthDay = 0;
return true;
case ReminderRecurrenceType::Weekly:
recurrence->monthDay = 0;
if (recurrence->weekday < 1 || recurrence->weekday > 7)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("每周提醒的星期无效。");
}
return false;
}
return true;
case ReminderRecurrenceType::Monthly:
recurrence->weekday = 0;
if (recurrence->monthDay < 1 || recurrence->monthDay > 31)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("每月提醒的日期无效。");
}
return false;
}
return true;
case ReminderRecurrenceType::None:
*recurrence = {};
return true;
}
*recurrence = {};
return true;
}
}
ReminderManager::ReminderManager()
{
QObject::connect(&m_timer, &QTimer::timeout, [this]() {
processDueReminders();
});
QObject::connect(&m_guardTimer, &QTimer::timeout, [this]() {
checkDueRemindersNow();
});
m_timer.setSingleShot(true);
load();
}
void ReminderManager::start()
{
if (!m_started)
{
m_started = true;
}
if (!m_guardTimer.isActive())
{
m_guardTimer.start(GuardTimerIntervalMs);
}
checkDueRemindersNow();
}
void ReminderManager::setTriggeredCallback(TriggeredCallback callback)
{
m_triggeredCallback = std::move(callback);
}
QVector<ReminderItem> ReminderManager::allReminders() const
{
return sortedReminders(m_items);
}
QVector<ReminderItem> ReminderManager::pendingReminders() const
{
QVector<ReminderItem> result;
for (const ReminderItem &item : m_items)
{
if (isPending(item))
{
result.append(item);
}
}
return sortedReminders(result);
}
ReminderCommand ReminderManager::parseCommand(const QString &text) const
{
return m_parser.parse(text);
}
void ReminderManager::checkDueRemindersNow()
{
if (!m_started)
{
return;
}
processDueReminders();
QString errorMessage;
if (!pruneFinishedReminders(DefaultHistoryRetentionDays, &errorMessage))
{
Logger::warning(QStringLiteral("Failed to prune old reminder history: ") + errorMessage);
}
}
bool ReminderManager::createReminder(
const QString &title,
const QString &originalText,
const QDateTime &remindAt,
ReminderItem *createdItem,
QString *errorMessage)
{
return createReminder(title, originalText, remindAt, ReminderRecurrence{}, createdItem, errorMessage);
}
bool ReminderManager::createReminder(
const QString &title,
const QString &originalText,
const QDateTime &remindAt,
const ReminderRecurrence &recurrence,
ReminderItem *createdItem,
QString *errorMessage)
{
if (!remindAt.isValid() || remindAt <= QDateTime::currentDateTime())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("提醒时间必须晚于当前时间。");
}
return false;
}
ReminderRecurrence normalizedRecurrence = recurrence;
if (!normalizeRecurrence(&normalizedRecurrence, errorMessage))
{
return false;
}
ReminderItem item;
item.id = nextReminderId();
item.title = title.trimmed().isEmpty() ? QStringLiteral("提醒") : title.trimmed();
item.originalText = originalText;
item.remindAt = remindAt;
item.status = ReminderStatus::Pending;
item.createdAt = QDateTime::currentDateTime();
item.soundId.clear();
item.recurrence = normalizedRecurrence;
if (!reminderIsRecurring(item.recurrence))
{
item.recurrence = {};
}
m_items.append(item);
if (!save(errorMessage))
{
m_items.removeLast();
return false;
}
if (createdItem != nullptr)
{
*createdItem = item;
}
scheduleNextReminder();
return true;
}
bool ReminderManager::snoozeReminder(
const ReminderItem &sourceItem,
int minutes,
ReminderItem *createdItem,
QString *errorMessage)
{
if (minutes <= 0)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("稍后提醒时间必须晚于当前时间。");
}
return false;
}
const QString originalText = sourceItem.originalText.trimmed().isEmpty()
? sourceItem.title
: sourceItem.originalText;
return createReminder(
sourceItem.title,
originalText,
QDateTime::currentDateTime().addSecs(minutes * 60),
ReminderRecurrence{},
createdItem,
errorMessage);
}
bool ReminderManager::cancelReminder(const QString &id, QString *errorMessage)
{
const QString normalizedId = id.trimmed();
for (ReminderItem &item : m_items)
{
if (item.id == normalizedId && isPending(item))
{
const QDateTime previousFinishedAt = item.finishedAt;
item.status = ReminderStatus::Canceled;
item.finishedAt = QDateTime::currentDateTime();
const bool saved = save(errorMessage);
if (!saved)
{
item.status = ReminderStatus::Pending;
item.finishedAt = previousFinishedAt;
}
scheduleNextReminder();
return saved;
}
}
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("没有找到可取消的提醒。");
}
return false;
}
bool ReminderManager::cancelReminderByQuery(const QString &query, ReminderItem *canceledItem, QString *errorMessage)
{
QVector<int> matches;
for (int index = 0; index < m_items.size(); ++index)
{
if (isPending(m_items.at(index)) && textMatchesReminder(m_items.at(index), query))
{
matches.append(index);
}
}
if (matches.isEmpty())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("没有找到匹配的待提醒事项。");
}
return false;
}
if (matches.size() > 1)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("找到多条匹配提醒,请在设置页中选择具体提醒取消。");
}
return false;
}
ReminderItem &item = m_items[matches.first()];
const QDateTime previousFinishedAt = item.finishedAt;
item.status = ReminderStatus::Canceled;
item.finishedAt = QDateTime::currentDateTime();
const bool saved = save(errorMessage);
if (!saved)
{
item.status = ReminderStatus::Pending;
item.finishedAt = previousFinishedAt;
}
else if (canceledItem != nullptr)
{
*canceledItem = item;
}
scheduleNextReminder();
return saved;
}
bool ReminderManager::clearFinishedReminders(QString *errorMessage)
{
return pruneFinishedReminders(DefaultHistoryRetentionDays, errorMessage);
}
bool ReminderManager::pruneFinishedReminders(int retentionDays, QString *errorMessage)
{
QVector<ReminderItem> previousItems = m_items;
const int normalizedRetentionDays = qMax(0, retentionDays);
const QDateTime cutoff = QDateTime::currentDateTime().addDays(-normalizedRetentionDays);
for (int index = m_items.size() - 1; index >= 0; --index)
{
const ReminderItem &item = m_items.at(index);
if (isFinished(item) && finishedReferenceTime(item) < cutoff)
{
m_items.removeAt(index);
}
}
if (m_items.size() == previousItems.size())
{
return true;
}
if (!save(errorMessage))
{
m_items = previousItems;
return false;
}
scheduleNextReminder();
return true;
}
bool ReminderManager::updateReminder(
const QString &id,
const QString &title,
const QDateTime &remindAt,
const ReminderRecurrence &recurrence,
QString *errorMessage)
{
const QString normalizedId = id.trimmed();
for (ReminderItem &item : m_items)
{
if (item.id != normalizedId || !isPending(item))
{
continue;
}
const ReminderItem previousItem = item;
const QDateTime now = QDateTime::currentDateTime();
ReminderRecurrence normalizedRecurrence = recurrence;
if (!normalizeRecurrence(&normalizedRecurrence, errorMessage))
{
return false;
}
item.title = title.trimmed().isEmpty() ? QStringLiteral("提醒") : title.trimmed();
item.finishedAt = {};
if (reminderIsRecurring(normalizedRecurrence))
{
item.recurrence = normalizedRecurrence;
item.remindAt = nextRecurringOccurrence(item, now);
if (!item.remindAt.isValid())
{
item = previousItem;
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("没有找到有效的重复提醒时间。");
}
return false;
}
}
else
{
if (!remindAt.isValid() || remindAt <= now)
{
item = previousItem;
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("提醒时间必须晚于当前时间。");
}
return false;
}
item.remindAt = remindAt;
item.recurrence = {};
}
if (!save(errorMessage))
{
item = previousItem;
return false;
}
scheduleNextReminder();
return true;
}
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("没有找到可编辑的待提醒事项。");
}
return false;
}
QString ReminderManager::pendingReminderSummary() const
{
const QVector<ReminderItem> reminders = pendingReminders();
if (reminders.isEmpty())
{
return QStringLiteral("当前没有待提醒事项。");
}
QStringList lines;
lines.append(QStringLiteral("当前待提醒:"));
for (const ReminderItem &item : reminders)
{
lines.append(QStringLiteral("%1:%2(%3,下一次:%4)")
.arg(item.id, item.title, reminderRecurrenceDisplayText(item), reminderDisplayTime(item.remindAt)));
}
return lines.join(QChar('\n'));
}
void ReminderManager::load()
{
QString errorMessage;
m_items = m_store.load(&errorMessage);
if (!errorMessage.isEmpty())
{
Logger::warning(QStringLiteral("Failed to load reminders: ") + errorMessage);
}
}
bool ReminderManager::save(QString *errorMessage) const
{
return m_store.save(m_items, errorMessage);
}
void ReminderManager::processDueReminders()
{
const QDateTime now = QDateTime::currentDateTime();
QVector<ReminderItem> triggeredItems;
const QVector<ReminderItem> previousItems = m_items;
for (int index = 0; index < m_items.size(); ++index)
{
ReminderItem &item = m_items[index];
if (isPending(item) && item.remindAt <= now)
{
ReminderItem triggeredItem = item;
triggeredItem.status = ReminderStatus::Triggered;
triggeredItem.finishedAt = now;
triggeredItem.recurrence.lastTriggeredAt = now;
if (reminderIsRecurring(item))
{
const QDateTime nextOccurrence = nextRecurringOccurrence(item, now);
if (!nextOccurrence.isValid())
{
Logger::warning(QStringLiteral("Failed to compute next recurring reminder occurrence: id=%1").arg(item.id));
item.status = ReminderStatus::Triggered;
item.finishedAt = now;
}
else
{
item.recurrence.lastTriggeredAt = now;
item.remindAt = nextOccurrence;
item.finishedAt = {};
}
}
else
{
item.status = ReminderStatus::Triggered;
item.finishedAt = now;
}
triggeredItems.append(triggeredItem);
}
}
if (!triggeredItems.isEmpty())
{
QString errorMessage;
if (!save(&errorMessage))
{
Logger::warning(QStringLiteral("Failed to save triggered reminders: ") + errorMessage);
m_items = previousItems;
scheduleNextReminder();
return;
}
for (const ReminderItem &item : triggeredItems)
{
if (m_triggeredCallback)
{
m_triggeredCallback(item);
}
}
}
scheduleNextReminder();
}
void ReminderManager::scheduleNextReminder()
{
m_timer.stop();
if (!m_started)
{
return;
}
QDateTime nextReminderTime;
for (const ReminderItem &item : m_items)
{
if (!isPending(item))
{
continue;
}
if (!nextReminderTime.isValid() || item.remindAt < nextReminderTime)
{
nextReminderTime = item.remindAt;
}
}
if (!nextReminderTime.isValid())
{
return;
}
const qint64 delayMs = QDateTime::currentDateTime().msecsTo(nextReminderTime);
const int timerInterval = static_cast<int>(qBound<qint64>(MinimumTimerIntervalMs, delayMs, MaximumTimerIntervalMs));
m_timer.start(timerInterval);
}
QString ReminderManager::nextReminderId() const
{
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMddHHmmsszzz"));
const quint32 randomValue = QRandomGenerator::global()->bounded(10000U);
return QStringLiteral("reminder_%1_%2").arg(timestamp, QString::number(randomValue).rightJustified(4, QLatin1Char('0')));
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include "ReminderParser.h"
#include "ReminderStore.h"
#include <QTimer>
#include <QVector>
#include <functional>
class ReminderManager
{
public:
using TriggeredCallback = std::function<void(const ReminderItem &)>;
ReminderManager();
void start();
void setTriggeredCallback(TriggeredCallback callback);
QVector<ReminderItem> allReminders() const;
QVector<ReminderItem> pendingReminders() const;
ReminderCommand parseCommand(const QString &text) const;
void checkDueRemindersNow();
bool createReminder(
const QString &title,
const QString &originalText,
const QDateTime &remindAt,
ReminderItem *createdItem = nullptr,
QString *errorMessage = nullptr);
bool createReminder(
const QString &title,
const QString &originalText,
const QDateTime &remindAt,
const ReminderRecurrence &recurrence,
ReminderItem *createdItem = nullptr,
QString *errorMessage = nullptr);
bool snoozeReminder(
const ReminderItem &sourceItem,
int minutes,
ReminderItem *createdItem = nullptr,
QString *errorMessage = nullptr);
bool cancelReminder(const QString &id, QString *errorMessage = nullptr);
bool cancelReminderByQuery(const QString &query, ReminderItem *canceledItem = nullptr, QString *errorMessage = nullptr);
bool clearFinishedReminders(QString *errorMessage = nullptr);
bool pruneFinishedReminders(int retentionDays = 20, QString *errorMessage = nullptr);
bool updateReminder(
const QString &id,
const QString &title,
const QDateTime &remindAt,
const ReminderRecurrence &recurrence,
QString *errorMessage = nullptr);
QString pendingReminderSummary() const;
private:
void load();
bool save(QString *errorMessage = nullptr) const;
void processDueReminders();
void scheduleNextReminder();
QString nextReminderId() const;
ReminderStore m_store;
ReminderParser m_parser;
QVector<ReminderItem> m_items;
QTimer m_timer;
QTimer m_guardTimer;
TriggeredCallback m_triggeredCallback;
bool m_started = false;
};
+716
View File
@@ -0,0 +1,716 @@
#include "ReminderParser.h"
#include <QDate>
#include <QRegularExpression>
#include <QStringList>
#include <QTime>
namespace
{
bool containsAny(const QString &text, const QStringList &keywords)
{
for (const QString &keyword : keywords)
{
if (text.contains(keyword))
{
return true;
}
}
return false;
}
int chineseDigitValue(QChar value)
{
if (value == QLatin1Char('0') || value == QChar(0x3007) || value == QStringLiteral("零").at(0))
{
return 0;
}
if (value == QStringLiteral("一").at(0))
{
return 1;
}
if (value == QStringLiteral("二").at(0) || value == QStringLiteral("两").at(0))
{
return 2;
}
if (value == QStringLiteral("三").at(0))
{
return 3;
}
if (value == QStringLiteral("四").at(0))
{
return 4;
}
if (value == QStringLiteral("五").at(0))
{
return 5;
}
if (value == QStringLiteral("六").at(0))
{
return 6;
}
if (value == QStringLiteral("七").at(0))
{
return 7;
}
if (value == QStringLiteral("八").at(0))
{
return 8;
}
if (value == QStringLiteral("九").at(0))
{
return 9;
}
return -1;
}
int parseSmallInteger(QString value)
{
value = value.trimmed();
value.remove(QStringLiteral("个"));
if (value.isEmpty())
{
return -1;
}
bool ok = false;
const int numericValue = value.toInt(&ok);
if (ok)
{
return numericValue;
}
const int tenIndex = value.indexOf(QStringLiteral("十"));
if (tenIndex >= 0)
{
const QString left = value.left(tenIndex);
const QString right = value.mid(tenIndex + 1);
const int tens = left.isEmpty() ? 1 : parseSmallInteger(left);
const int ones = right.isEmpty() ? 0 : parseSmallInteger(right);
if (tens < 0 || ones < 0)
{
return -1;
}
return tens * 10 + ones;
}
if (value.size() == 1)
{
return chineseDigitValue(value.at(0));
}
return -1;
}
QString cleanedText(QString text)
{
return text
.replace(QChar(0xff0c), QStringLiteral(" "))
.replace(QChar(0x3002), QStringLiteral(" "))
.replace(QChar(0xff1a), QStringLiteral(":"))
.replace(QChar(0xff1b), QStringLiteral(" "))
.trimmed();
}
int adjustedHour(const QString &period, int hour)
{
if (hour < 0)
{
return -1;
}
if (period == QStringLiteral("下午") || period == QStringLiteral("晚上"))
{
return hour < 12 ? hour + 12 : hour;
}
if (period == QStringLiteral("中午"))
{
return hour < 11 ? hour + 12 : hour;
}
if (period == QStringLiteral("凌晨") && hour == 12)
{
return 0;
}
return hour;
}
int weekdayFromText(const QString &text)
{
const QString normalized = text.trimmed();
if (normalized == QStringLiteral("一") || normalized == QStringLiteral("1"))
{
return 1;
}
if (normalized == QStringLiteral("二") || normalized == QStringLiteral("2"))
{
return 2;
}
if (normalized == QStringLiteral("三") || normalized == QStringLiteral("3"))
{
return 3;
}
if (normalized == QStringLiteral("四") || normalized == QStringLiteral("4"))
{
return 4;
}
if (normalized == QStringLiteral("五") || normalized == QStringLiteral("5"))
{
return 5;
}
if (normalized == QStringLiteral("六") || normalized == QStringLiteral("6"))
{
return 6;
}
if (normalized == QStringLiteral("日")
|| normalized == QStringLiteral("天")
|| normalized == QStringLiteral("7"))
{
return 7;
}
return -1;
}
struct ReminderDateResolution
{
QDate date;
bool explicitDate = false;
};
struct ParsedReminderTime
{
int hour = -1;
int minute = -1;
QString expression;
};
ReminderDateResolution resolveReminderDate(const QString &text, const QDate &currentDate)
{
QRegularExpressionMatch match;
const QRegularExpression nextWeekExpression(QStringLiteral("下周\\s*([一二三四五六日天1-7])"));
match = nextWeekExpression.match(text);
if (match.hasMatch())
{
const int targetWeekday = weekdayFromText(match.captured(1));
if (targetWeekday > 0)
{
const int daysToNextMonday = 8 - currentDate.dayOfWeek();
return {currentDate.addDays(daysToNextMonday + targetWeekday - 1), true};
}
}
const QRegularExpression monthDayExpression(QStringLiteral("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*(?:日|号)?"));
match = monthDayExpression.match(text);
if (match.hasMatch())
{
const int month = match.captured(1).toInt();
const int day = match.captured(2).toInt();
QDate date(currentDate.year(), month, day);
if (date.isValid() && date < currentDate)
{
date = date.addYears(1);
}
return {date, true};
}
const QRegularExpression slashDateExpression(QStringLiteral("(\\d{1,2})\\s*/\\s*(\\d{1,2})"));
match = slashDateExpression.match(text);
if (match.hasMatch())
{
const int month = match.captured(1).toInt();
const int day = match.captured(2).toInt();
QDate date(currentDate.year(), month, day);
if (date.isValid() && date < currentDate)
{
date = date.addYears(1);
}
return {date, true};
}
if (text.contains(QStringLiteral("后天")))
{
return {currentDate.addDays(2), true};
}
if (text.contains(QStringLiteral("明天")))
{
return {currentDate.addDays(1), true};
}
if (text.contains(QStringLiteral("今天")))
{
return {currentDate, true};
}
return {currentDate, false};
}
QString removeFirst(const QString &text, const QString &part)
{
QString result = text;
const int index = result.indexOf(part);
if (index >= 0)
{
result.remove(index, part.size());
}
return result;
}
ReminderCommand invalidRecurringCommand(const QString &text, const QString &message)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, message};
}
ReminderCommand createReminderCommand(
const QString &title,
const QString &originalText,
const QDateTime &remindAt,
const ReminderRecurrence &recurrence = {})
{
ReminderCommand command;
command.type = ReminderCommandType::Create;
command.title = title;
command.originalText = originalText;
command.remindAt = remindAt;
command.recurrence = recurrence;
return command;
}
ParsedReminderTime parsedTimeFromPointMatch(const QRegularExpressionMatch &match, int periodIndex, int hourIndex, int minuteIndex)
{
const QString period = match.captured(periodIndex);
const int hour = adjustedHour(period, parseSmallInteger(match.captured(hourIndex)));
const int minute = match.captured(minuteIndex).isEmpty() ? 0 : match.captured(minuteIndex).toInt();
if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
{
return {};
}
return {hour, minute, match.captured(0)};
}
ParsedReminderTime parsedTimeFromColonMatch(const QRegularExpressionMatch &match, int hourIndex, int minuteIndex)
{
const int hour = match.captured(hourIndex).toInt();
const int minute = match.captured(minuteIndex).toInt();
if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
{
return {};
}
return {hour, minute, match.captured(0)};
}
ParsedReminderTime parseTimeExpression(const QString &text)
{
QRegularExpressionMatch match;
const QRegularExpression pointExpression(QStringLiteral("(上午|早上|下午|晚上|中午|凌晨)?\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*点\\s*(?:(\\d{1,2})\\s*分?)?"));
match = pointExpression.match(text);
if (match.hasMatch())
{
return parsedTimeFromPointMatch(match, 1, 2, 3);
}
const QRegularExpression colonExpression(QStringLiteral("([01]?\\d|2[0-3])\\s*[::]\\s*([0-5]\\d)"));
match = colonExpression.match(text);
if (match.hasMatch())
{
return parsedTimeFromColonMatch(match, 1, 2);
}
return {};
}
QDateTime nextDailyOccurrence(const QDateTime &now, const QTime &time)
{
QDateTime next(now.date(), time);
if (next <= now)
{
next = next.addDays(1);
}
return next;
}
QDateTime nextWeeklyOccurrence(const QDateTime &now, int weekday, const QTime &time)
{
int daysToAdd = weekday - now.date().dayOfWeek();
QDateTime next(now.date().addDays(daysToAdd), time);
if (daysToAdd < 0 || next <= now)
{
next = next.addDays(7);
}
return next;
}
QDateTime nextMonthlyOccurrence(const QDateTime &now, int monthDay, const QTime &time)
{
if (monthDay < 1 || monthDay > 31)
{
return {};
}
QDate monthCursor(now.date().year(), now.date().month(), 1);
for (int attempt = 0; attempt < 240; ++attempt)
{
const QDate date(monthCursor.year(), monthCursor.month(), monthDay);
if (date.isValid())
{
const QDateTime next(date, time);
if (next > now)
{
return next;
}
}
monthCursor = monthCursor.addMonths(1);
}
return {};
}
bool containsUnsupportedRecurrence(const QString &text)
{
static const QRegularExpression intervalExpression(QStringLiteral("每\\s*([0-9]+|[一二两三四五六七八九十]+)\\s*(天|周|星期|月)"));
return containsAny(text, {
QStringLiteral("每年"),
QStringLiteral("工作日"),
QStringLiteral("重复"),
QStringLiteral("每隔"),
QStringLiteral("隔天"),
QStringLiteral("每季度"),
QStringLiteral("每季"),
QStringLiteral("每小时"),
QStringLiteral("每分钟"),
QStringLiteral("农历"),
}) || intervalExpression.match(text).hasMatch();
}
}
ReminderCommand ReminderParser::parse(const QString &text, const QDateTime &now) const
{
const QString normalized = cleanedText(text);
if (normalized.isEmpty())
{
return {ReminderCommandType::Invalid, {}, {}, {}, {}, QStringLiteral("提醒内容为空。")};
}
if (containsAny(normalized, {QStringLiteral("提醒列表"), QStringLiteral("查看提醒"), QStringLiteral("我的提醒"), QStringLiteral("有哪些提醒")}))
{
return {ReminderCommandType::List, {}, normalized};
}
if (normalized.contains(QStringLiteral("取消提醒")) || normalized.startsWith(QStringLiteral("取消")))
{
QString query = normalized;
query.remove(QStringLiteral("取消提醒"));
if (query == normalized)
{
query.remove(QStringLiteral("取消"));
query.remove(QStringLiteral("提醒"));
}
query = query.trimmed();
if (query.isEmpty())
{
return {ReminderCommandType::Invalid, {}, normalized, {}, {}, QStringLiteral("请说明要取消哪条提醒。")};
}
return {ReminderCommandType::Cancel, {}, normalized, {}, query};
}
return parseCreateCommand(normalized, now);
}
ReminderCommand ReminderParser::parseCreateCommand(const QString &text, const QDateTime &now) const
{
const QDate currentDate = now.date();
const ReminderCommand recurringCommand = parseRecurringCreateCommand(text, now);
if (recurringCommand.type != ReminderCommandType::Invalid || !recurringCommand.errorMessage.isEmpty())
{
return recurringCommand;
}
if (containsUnsupportedRecurrence(text))
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("暂不支持该重复提醒规则,目前支持每天、每周、每月。")};
}
const QRegularExpression relativeMinutesExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*分钟后"));
QRegularExpressionMatch match = relativeMinutesExpression.match(text);
if (match.hasMatch())
{
const int minutes = parseSmallInteger(match.captured(1));
if (minutes <= 0)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
}
const QString timeExpression = match.captured(0);
return {
ReminderCommandType::Create,
extractTitle(text, timeExpression),
text,
now.addSecs(minutes * 60),
{},
{},
};
}
const QRegularExpression relativeOneAndHalfHourExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*(?:个)?半小时后"));
match = relativeOneAndHalfHourExpression.match(text);
if (match.hasMatch())
{
const int hours = parseSmallInteger(match.captured(1));
if (hours < 0)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒时间。")};
}
const QString timeExpression = match.captured(0);
return {
ReminderCommandType::Create,
extractTitle(text, timeExpression),
text,
now.addSecs(hours * 60 * 60 + 30 * 60),
{},
{},
};
}
if (text.contains(QStringLiteral("半小时后")))
{
const QString timeExpression = QStringLiteral("半小时后");
return {
ReminderCommandType::Create,
extractTitle(text, timeExpression),
text,
now.addSecs(30 * 60),
{},
{},
};
}
const QRegularExpression relativeHoursExpression(QStringLiteral("([0-9]+|[一二两三四五六七八九十]+)\\s*(?:个)?小时后"));
match = relativeHoursExpression.match(text);
if (match.hasMatch())
{
const int hours = parseSmallInteger(match.captured(1));
if (hours <= 0)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
}
const QString timeExpression = match.captured(0);
return {
ReminderCommandType::Create,
extractTitle(text, timeExpression),
text,
now.addSecs(hours * 60 * 60),
{},
{},
};
}
const ReminderDateResolution dateResolution = resolveReminderDate(text, currentDate);
if (!dateResolution.date.isValid())
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒日期。")};
}
const QRegularExpression clockExpression(QStringLiteral("(上午|早上|下午|晚上|中午|凌晨)?\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*点\\s*(?:(\\d{1,2})\\s*分?)?"));
match = clockExpression.match(text);
if (match.hasMatch())
{
const QString period = match.captured(1);
int hour = adjustedHour(period, parseSmallInteger(match.captured(2)));
const int minute = match.captured(3).isEmpty() ? 0 : match.captured(3).toInt();
if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到有效提醒时间。")};
}
QDateTime remindAt(dateResolution.date, QTime(hour, minute));
if (remindAt <= now)
{
if (dateResolution.explicitDate)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
}
remindAt = remindAt.addDays(1);
}
return {
ReminderCommandType::Create,
extractTitle(text, match.captured(0)),
text,
remindAt,
{},
{},
};
}
const QRegularExpression colonClockExpression(QStringLiteral("(明天)?\\s*(?:在)?\\s*([01]?\\d|2[0-3])\\s*[::]\\s*([0-5]\\d)"));
match = colonClockExpression.match(text);
if (match.hasMatch())
{
QDateTime remindAt(dateResolution.date, QTime(match.captured(2).toInt(), match.captured(3).toInt()));
if (remindAt <= now)
{
if (dateResolution.explicitDate)
{
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("提醒时间必须晚于当前时间。")};
}
remindAt = remindAt.addDays(1);
}
return {
ReminderCommandType::Create,
extractTitle(text, match.captured(0)),
text,
remindAt,
{},
{},
};
}
return {ReminderCommandType::Invalid, {}, text, {}, {}, QStringLiteral("没有识别到提醒时间。支持如“10分钟后提醒我喝水”“明天9点提醒我开会”。")};
}
ReminderCommand ReminderParser::parseRecurringCreateCommand(const QString &text, const QDateTime &now) const
{
QRegularExpressionMatch match;
if (text.contains(QStringLiteral("每月"))
&& (text.contains(QStringLiteral("最后")) || text.contains(QStringLiteral("月末"))))
{
return invalidRecurringCommand(text, QStringLiteral("暂不支持每月最后一天提醒,目前支持每月具体日期。"));
}
if (text.contains(QStringLiteral("每天")) || text.contains(QStringLiteral("每日")))
{
const ParsedReminderTime parsedTime = parseTimeExpression(text);
if (parsedTime.hour < 0)
{
return invalidRecurringCommand(text, QStringLiteral("没有识别到重复提醒时间。支持如“每天9点提醒我打卡”。"));
}
const QTime time(parsedTime.hour, parsedTime.minute);
ReminderRecurrence recurrence;
recurrence.type = ReminderRecurrenceType::Daily;
recurrence.interval = 1;
recurrence.hour = parsedTime.hour;
recurrence.minute = parsedTime.minute;
return createReminderCommand(
extractTitle(text, parsedTime.expression),
text,
nextDailyOccurrence(now, time),
recurrence);
}
if (text.contains(QStringLiteral("每周")) || text.contains(QStringLiteral("每星期")))
{
const QRegularExpression weeklyExpression(QStringLiteral("(?:每周|每星期)\\s*([一二三四五六日天1-7])"));
match = weeklyExpression.match(text);
const int weekday = match.hasMatch() ? weekdayFromText(match.captured(1)) : -1;
const ParsedReminderTime parsedTime = parseTimeExpression(text);
if (weekday < 1 || parsedTime.hour < 0)
{
return invalidRecurringCommand(text, QStringLiteral("没有识别到重复提醒时间。支持如“每周一上午10点提醒我周会”。"));
}
const QTime time(parsedTime.hour, parsedTime.minute);
ReminderRecurrence recurrence;
recurrence.type = ReminderRecurrenceType::Weekly;
recurrence.interval = 1;
recurrence.weekday = weekday;
recurrence.hour = parsedTime.hour;
recurrence.minute = parsedTime.minute;
return createReminderCommand(
extractTitle(text, parsedTime.expression),
text,
nextWeeklyOccurrence(now, weekday, time),
recurrence);
}
if (text.contains(QStringLiteral("每月")))
{
const QRegularExpression monthlyExpression(QStringLiteral("每月\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*(?:日|号)?"));
match = monthlyExpression.match(text);
const int monthDay = match.hasMatch() ? parseSmallInteger(match.captured(1)) : -1;
const ParsedReminderTime parsedTime = parseTimeExpression(text);
if (monthDay < 1 || monthDay > 31 || parsedTime.hour < 0)
{
return invalidRecurringCommand(text, QStringLiteral("没有识别到重复提醒时间。支持如“每月3号9点提醒我交报告”。"));
}
const QTime time(parsedTime.hour, parsedTime.minute);
const QDateTime remindAt = nextMonthlyOccurrence(now, monthDay, time);
if (!remindAt.isValid())
{
return invalidRecurringCommand(text, QStringLiteral("没有找到有效的每月提醒日期。"));
}
ReminderRecurrence recurrence;
recurrence.type = ReminderRecurrenceType::Monthly;
recurrence.interval = 1;
recurrence.monthDay = monthDay;
recurrence.hour = parsedTime.hour;
recurrence.minute = parsedTime.minute;
return createReminderCommand(
extractTitle(text, parsedTime.expression),
text,
remindAt,
recurrence);
}
return {};
}
QString ReminderParser::extractTitle(QString text, const QString &timeExpression) const
{
text = removeFirst(text, timeExpression);
text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*月\\s*\\d{1,2}\\s*(?:日|号)?")));
text.remove(QRegularExpression(QStringLiteral("\\d{1,2}\\s*/\\s*\\d{1,2}")));
text.remove(QRegularExpression(QStringLiteral("下周\\s*[一二三四五六日天1-7]")));
text.remove(QRegularExpression(QStringLiteral("每周\\s*[一二三四五六日天1-7]")));
text.remove(QRegularExpression(QStringLiteral("每星期\\s*[一二三四五六日天1-7]")));
text.remove(QRegularExpression(QStringLiteral("每月\\s*([0-9]{1,2}|[一二两三四五六七八九十]+)\\s*(?:日|号)?")));
const QStringList tokensToRemove = {
QStringLiteral("提醒我"),
QStringLiteral("提醒"),
QStringLiteral("叫我"),
QStringLiteral("到点"),
QStringLiteral("的时候"),
QStringLiteral("请"),
QStringLiteral("帮我"),
QStringLiteral("今天"),
QStringLiteral("明天"),
QStringLiteral("后天"),
QStringLiteral("每天"),
QStringLiteral("每日"),
QStringLiteral("每周"),
QStringLiteral("每星期"),
QStringLiteral("每月"),
};
for (const QString &token : tokensToRemove)
{
text.remove(token);
}
text = text.trimmed();
while (text.startsWith(QChar(0x3000)) || text.startsWith(QLatin1Char(' ')) || text.startsWith(QLatin1Char(',')) || text.startsWith(QChar(0xff0c)))
{
text.remove(0, 1);
text = text.trimmed();
}
return text.isEmpty() ? QStringLiteral("提醒") : text;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "ReminderTypes.h"
#include <QDateTime>
#include <QString>
class ReminderParser
{
public:
ReminderCommand parse(const QString &text, const QDateTime &now = QDateTime::currentDateTime()) const;
private:
ReminderCommand parseCreateCommand(const QString &text, const QDateTime &now) const;
ReminderCommand parseRecurringCreateCommand(const QString &text, const QDateTime &now) const;
QString extractTitle(QString text, const QString &timeExpression) const;
};
+22
View File
@@ -0,0 +1,22 @@
#include "ReminderSoundPlayer.h"
#include "ReminderSoundRepository.h"
#include <QFileInfo>
#include <QUrl>
#include <QtGlobal>
void ReminderSoundPlayer::play(const QString &soundId, double volume)
{
const QString path = ReminderSoundRepository::soundPath(soundId);
if (!QFileInfo::exists(path))
{
return;
}
m_soundEffect.stop();
m_soundEffect.setLoopCount(1);
m_soundEffect.setVolume(static_cast<float>(qBound(0.0, volume, 1.0)));
m_soundEffect.setSource(QUrl::fromLocalFile(path));
m_soundEffect.play();
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QSoundEffect>
#include <QString>
class ReminderSoundPlayer
{
public:
void play(const QString &soundId, double volume);
private:
QSoundEffect m_soundEffect;
};
+385
View File
@@ -0,0 +1,385 @@
#include "ReminderSoundRepository.h"
#include "../util/ResourcePaths.h"
#include <QDataStream>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QStringList>
namespace
{
constexpr qint64 MaxSoundFileBytes = 5 * 1024 * 1024;
constexpr int MaxSoundDurationSeconds = 30;
QString appDataPath()
{
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
return path.isEmpty() ? QDir::currentPath() : path;
}
QString sanitizedSoundId(QString value)
{
value = value.trimmed().toLower();
value.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_"));
while (value.startsWith(QLatin1Char('.')) || value.startsWith(QLatin1Char('_')) || value.startsWith(QLatin1Char('-')))
{
value.remove(0, 1);
}
while (value.endsWith(QLatin1Char('.')) || value.endsWith(QLatin1Char('_')) || value.endsWith(QLatin1Char('-')))
{
value.chop(1);
}
return value.isEmpty() ? QStringLiteral("reminder_sound") : value;
}
QString displayNameForBuiltInSound(const QString &id)
{
if (id == QStringLiteral("reminder_default"))
{
return QStringLiteral("默认提醒");
}
if (id == QStringLiteral("reminder_soft"))
{
return QStringLiteral("柔和提醒");
}
return id;
}
ReminderSoundInfo builtInSound(const QString &id)
{
return {
id,
displayNameForBuiltInSound(id),
ResourcePaths::reminderSoundsRootPath() + QLatin1Char('/') + id + QStringLiteral(".wav"),
true,
};
}
bool readChunkHeader(QDataStream &stream, QByteArray *id, quint32 *size)
{
char chunkId[4] = {};
if (stream.readRawData(chunkId, 4) != 4)
{
return false;
}
*id = QByteArray(chunkId, 4);
stream >> *size;
return stream.status() == QDataStream::Ok;
}
bool isSafeSoundId(const QString &soundId)
{
static const QRegularExpression expression(QStringLiteral("^[A-Za-z0-9][A-Za-z0-9._-]*$"));
return expression.match(soundId).hasMatch()
&& !soundId.contains(QLatin1Char('/'))
&& !soundId.contains(QLatin1Char('\\'));
}
bool pathStaysInsideDirectory(const QString &filePath, const QString &directoryPath)
{
const QString absoluteFilePath = QDir::cleanPath(QFileInfo(filePath).absoluteFilePath());
const QString absoluteDirectoryPath = QDir::cleanPath(QDir(directoryPath).absolutePath());
return absoluteFilePath == absoluteDirectoryPath
|| absoluteFilePath.startsWith(absoluteDirectoryPath + QLatin1Char('/'));
}
}
QString ReminderSoundRepository::defaultSoundId()
{
return QStringLiteral("reminder_default");
}
QVector<ReminderSoundInfo> ReminderSoundRepository::availableSounds()
{
QVector<ReminderSoundInfo> sounds;
const QStringList builtInIds = {
QStringLiteral("reminder_default"),
QStringLiteral("reminder_soft"),
};
for (const QString &id : builtInIds)
{
const ReminderSoundInfo info = builtInSound(id);
if (QFileInfo::exists(info.path))
{
sounds.append(info);
}
}
QDir userRoot(userSoundsRootPath());
const QFileInfoList userFiles = userRoot.entryInfoList({QStringLiteral("*.wav")}, QDir::Files, QDir::Name);
for (const QFileInfo &fileInfo : userFiles)
{
const QString id = fileInfo.completeBaseName();
if (isBuiltInSound(id))
{
continue;
}
sounds.append({
id,
id,
QDir::cleanPath(fileInfo.absoluteFilePath()),
false,
});
}
return sounds;
}
ReminderSoundInfo ReminderSoundRepository::soundInfo(const QString &soundId)
{
const QString normalizedId = soundId.trimmed().isEmpty() ? defaultSoundId() : soundId.trimmed();
for (const ReminderSoundInfo &info : availableSounds())
{
if (info.id == normalizedId)
{
return info;
}
}
return builtInSound(defaultSoundId());
}
QString ReminderSoundRepository::soundPath(const QString &soundId)
{
return soundInfo(soundId).path;
}
QString ReminderSoundRepository::userSoundsRootPath()
{
return QDir(appDataPath()).filePath(QStringLiteral("sounds/reminders"));
}
bool ReminderSoundRepository::isBuiltInSound(const QString &soundId)
{
return soundId == QStringLiteral("reminder_default")
|| soundId == QStringLiteral("reminder_soft");
}
bool ReminderSoundRepository::importSoundFile(const QString &sourcePath, QString *importedSoundId, QString *errorMessage)
{
const QFileInfo sourceInfo(sourcePath);
if (!sourceInfo.exists() || !sourceInfo.isFile())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效文件不存在。");
}
return false;
}
if (sourceInfo.suffix().compare(QStringLiteral("wav"), Qt::CaseInsensitive) != 0)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("只支持导入 wav 音效。");
}
return false;
}
if (!validateWaveFile(sourceInfo.absoluteFilePath(), errorMessage))
{
return false;
}
QDir userRoot(userSoundsRootPath());
if (!userRoot.exists() && !userRoot.mkpath(QStringLiteral(".")))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法创建用户音效目录。");
}
return false;
}
QString id = sanitizedSoundId(sourceInfo.completeBaseName());
if (isBuiltInSound(id))
{
id.prepend(QStringLiteral("user_"));
}
QString targetPath = userRoot.filePath(id + QStringLiteral(".wav"));
int suffix = 2;
while (QFileInfo::exists(targetPath))
{
targetPath = userRoot.filePath(id + QStringLiteral("_") + QString::number(suffix) + QStringLiteral(".wav"));
++suffix;
}
const QString finalId = QFileInfo(targetPath).completeBaseName();
if (!QFile::copy(sourceInfo.absoluteFilePath(), targetPath))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("复制音效文件失败。");
}
return false;
}
if (importedSoundId != nullptr)
{
*importedSoundId = finalId;
}
return true;
}
bool ReminderSoundRepository::deleteUserSound(const QString &soundId, QString *errorMessage)
{
const QString id = soundId.trimmed();
if (id.isEmpty() || isBuiltInSound(id))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("内置提醒音效不能删除。");
}
return false;
}
if (!isSafeSoundId(id))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效 id 不安全。");
}
return false;
}
const QString userRootPath = userSoundsRootPath();
const QString path = QDir(userRootPath).filePath(id + QStringLiteral(".wav"));
if (!pathStaysInsideDirectory(path, userRootPath))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效路径不安全。");
}
return false;
}
QFile file(path);
if (!file.exists())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("没有找到可删除的用户音效。");
}
return false;
}
if (!file.remove())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("删除用户音效失败。");
}
return false;
}
return true;
}
bool ReminderSoundRepository::validateWaveFile(const QString &path, QString *errorMessage)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法读取音效文件。");
}
return false;
}
if (file.size() <= 44 || file.size() > MaxSoundFileBytes)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效文件大小不符合要求。");
}
return false;
}
QDataStream stream(&file);
stream.setByteOrder(QDataStream::LittleEndian);
char riff[4] = {};
char wave[4] = {};
quint32 riffSize = 0;
if (stream.readRawData(riff, 4) != 4)
{
return false;
}
stream >> riffSize;
if (stream.readRawData(wave, 4) != 4
|| QByteArray(riff, 4) != QByteArray("RIFF", 4)
|| QByteArray(wave, 4) != QByteArray("WAVE", 4))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("不是有效的 RIFF/WAVE 文件。");
}
return false;
}
quint16 audioFormat = 0;
quint16 channels = 0;
quint32 sampleRate = 0;
quint16 bitsPerSample = 0;
quint32 dataSize = 0;
bool hasFmt = false;
bool hasData = false;
while (!stream.atEnd())
{
QByteArray chunkId;
quint32 chunkSize = 0;
if (!readChunkHeader(stream, &chunkId, &chunkSize))
{
break;
}
const qint64 chunkStart = file.pos();
if (chunkId == QByteArray("fmt ", 4))
{
quint32 byteRate = 0;
quint16 blockAlign = 0;
stream >> audioFormat >> channels >> sampleRate >> byteRate >> blockAlign >> bitsPerSample;
hasFmt = true;
}
else if (chunkId == QByteArray("data", 4))
{
dataSize = chunkSize;
hasData = true;
}
file.seek(chunkStart + chunkSize + (chunkSize % 2));
}
if (!hasFmt || !hasData || audioFormat != 1 || channels == 0 || sampleRate == 0 || bitsPerSample == 0 || dataSize == 0)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效文件格式不受支持。请使用 PCM wav。");
}
return false;
}
const double durationSeconds = static_cast<double>(dataSize) / static_cast<double>(sampleRate * channels * (bitsPerSample / 8.0));
if (durationSeconds <= 0.0 || durationSeconds > MaxSoundDurationSeconds)
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("音效时长不符合要求。");
}
return false;
}
return true;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QString>
#include <QVector>
struct ReminderSoundInfo
{
QString id;
QString displayName;
QString path;
bool builtIn = false;
};
class ReminderSoundRepository
{
public:
static QString defaultSoundId();
static QVector<ReminderSoundInfo> availableSounds();
static ReminderSoundInfo soundInfo(const QString &soundId);
static QString soundPath(const QString &soundId);
static QString userSoundsRootPath();
static bool isBuiltInSound(const QString &soundId);
static bool importSoundFile(const QString &sourcePath, QString *importedSoundId = nullptr, QString *errorMessage = nullptr);
static bool deleteUserSound(const QString &soundId, QString *errorMessage = nullptr);
static bool validateWaveFile(const QString &path, QString *errorMessage = nullptr);
};
+286
View File
@@ -0,0 +1,286 @@
#include "ReminderStore.h"
#include "../util/Logger.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QSaveFile>
#include <QStandardPaths>
#include <QtGlobal>
namespace
{
const QString ReminderStoreFileName = QStringLiteral("reminders.json");
QJsonObject recurrenceToObject(const ReminderRecurrence &recurrence)
{
QJsonObject object;
object.insert(QStringLiteral("type"), reminderRecurrenceTypeToString(recurrence.type));
object.insert(QStringLiteral("interval"), qMax(1, recurrence.interval));
object.insert(QStringLiteral("weekday"), recurrence.weekday);
object.insert(QStringLiteral("monthDay"), recurrence.monthDay);
object.insert(QStringLiteral("hour"), recurrence.hour);
object.insert(QStringLiteral("minute"), recurrence.minute);
if (recurrence.lastTriggeredAt.isValid())
{
object.insert(QStringLiteral("lastTriggeredAt"), recurrence.lastTriggeredAt.toString(Qt::ISODate));
}
return object;
}
ReminderRecurrence recurrenceFromObject(const QJsonObject &object)
{
ReminderRecurrence recurrence;
recurrence.type = reminderRecurrenceTypeFromString(object.value(QStringLiteral("type")).toString());
recurrence.interval = qMax(1, object.value(QStringLiteral("interval")).toInt(1));
recurrence.weekday = object.value(QStringLiteral("weekday")).toInt(0);
recurrence.monthDay = object.value(QStringLiteral("monthDay")).toInt(0);
recurrence.hour = object.value(QStringLiteral("hour")).toInt(-1);
recurrence.minute = object.value(QStringLiteral("minute")).toInt(-1);
recurrence.lastTriggeredAt = QDateTime::fromString(
object.value(QStringLiteral("lastTriggeredAt")).toString(),
Qt::ISODate);
if (recurrence.type == ReminderRecurrenceType::None)
{
recurrence = {};
}
return recurrence;
}
bool recurrenceIsValid(const ReminderRecurrence &recurrence)
{
if (recurrence.type == ReminderRecurrenceType::None)
{
return true;
}
if (recurrence.interval < 1
|| recurrence.hour < 0
|| recurrence.hour > 23
|| recurrence.minute < 0
|| recurrence.minute > 59)
{
return false;
}
if (recurrence.type == ReminderRecurrenceType::Weekly)
{
return recurrence.weekday >= 1 && recurrence.weekday <= 7;
}
if (recurrence.type == ReminderRecurrenceType::Monthly)
{
return recurrence.monthDay >= 1 && recurrence.monthDay <= 31;
}
return recurrence.type == ReminderRecurrenceType::Daily;
}
QJsonObject reminderToObject(const ReminderItem &item)
{
QJsonObject object;
object.insert(QStringLiteral("id"), item.id);
object.insert(QStringLiteral("title"), item.title);
object.insert(QStringLiteral("originalText"), item.originalText);
object.insert(QStringLiteral("remindAt"), item.remindAt.toString(Qt::ISODate));
object.insert(QStringLiteral("status"), reminderStatusToString(item.status));
object.insert(QStringLiteral("createdAt"), item.createdAt.toString(Qt::ISODate));
if (item.finishedAt.isValid())
{
object.insert(QStringLiteral("finishedAt"), item.finishedAt.toString(Qt::ISODate));
}
object.insert(QStringLiteral("soundId"), item.soundId);
object.insert(QStringLiteral("recurrence"), recurrenceToObject(item.recurrence));
return object;
}
ReminderItem reminderFromObject(const QJsonObject &object)
{
ReminderItem item;
item.id = object.value(QStringLiteral("id")).toString().trimmed();
item.title = object.value(QStringLiteral("title")).toString().trimmed();
item.originalText = object.value(QStringLiteral("originalText")).toString();
item.remindAt = QDateTime::fromString(object.value(QStringLiteral("remindAt")).toString(), Qt::ISODate);
item.status = reminderStatusFromString(object.value(QStringLiteral("status")).toString());
item.createdAt = QDateTime::fromString(object.value(QStringLiteral("createdAt")).toString(), Qt::ISODate);
item.finishedAt = QDateTime::fromString(object.value(QStringLiteral("finishedAt")).toString(), Qt::ISODate);
item.soundId = object.value(QStringLiteral("soundId")).toString().trimmed();
item.recurrence = recurrenceFromObject(object.value(QStringLiteral("recurrence")).toObject());
if (!recurrenceIsValid(item.recurrence))
{
Logger::warning(QStringLiteral("Invalid reminder recurrence downgraded to one-shot: id=%1").arg(item.id));
item.recurrence = {};
}
return item;
}
}
QVector<ReminderItem> ReminderStore::load(QString *errorMessage) const
{
QFile file(storePath());
if (!file.exists())
{
return {};
}
if (!file.open(QIODevice::ReadOnly))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法读取提醒文件。");
}
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
file.close();
backupBrokenStore(storePath());
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("提醒文件损坏,已备份并使用空提醒列表。");
}
return {};
}
QVector<ReminderItem> items;
const QJsonArray reminders = document.object().value(QStringLiteral("reminders")).toArray();
for (const QJsonValue &value : reminders)
{
if (!value.isObject())
{
continue;
}
ReminderItem item = reminderFromObject(value.toObject());
if (item.id.isEmpty() || !item.remindAt.isValid())
{
continue;
}
if (item.title.isEmpty())
{
item.title = QStringLiteral("提醒");
}
if (!item.createdAt.isValid())
{
item.createdAt = item.remindAt;
}
if (!item.finishedAt.isValid()
&& (item.status == ReminderStatus::Triggered || item.status == ReminderStatus::Canceled))
{
item.finishedAt = item.remindAt;
}
items.append(item);
}
return sortedReminders(items);
}
bool ReminderStore::save(const QVector<ReminderItem> &items, QString *errorMessage) const
{
QDir directory(configDirectoryPath());
if (!directory.exists() && !directory.mkpath(QStringLiteral(".")))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法创建提醒配置目录。");
}
return false;
}
QJsonArray reminders;
for (const ReminderItem &item : items)
{
reminders.append(reminderToObject(item));
}
QJsonObject root;
root.insert(QStringLiteral("reminders"), reminders);
QSaveFile file(storePath());
if (!file.open(QIODevice::WriteOnly))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法写入提醒文件。");
}
return false;
}
const QJsonDocument document(root);
const QByteArray payload = document.toJson(QJsonDocument::Indented);
if (file.write(payload) != payload.size())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("写入提醒文件不完整。");
}
file.cancelWriting();
return false;
}
if (!file.commit())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("提交提醒文件失败。");
}
return false;
}
return true;
}
QString ReminderStore::storePath() const
{
return QDir(configDirectoryPath()).filePath(ReminderStoreFileName);
}
QString ReminderStore::configDirectoryPath() const
{
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
return path.isEmpty() ? QDir::currentPath() : path;
}
void ReminderStore::backupBrokenStore(const QString &filePath) const
{
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(QStringLiteral("reminders.broken.") + timestamp + QStringLiteral(".json"));
int suffix = 1;
while (QFile::exists(backupPath))
{
backupPath = fileInfo.dir().filePath(
QStringLiteral("reminders.broken.")
+ timestamp
+ QStringLiteral("-")
+ QString::number(suffix)
+ QStringLiteral(".json"));
++suffix;
}
if (!file.rename(backupPath))
{
Logger::warning(QStringLiteral("Failed to back up broken reminders file: ") + filePath);
}
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "ReminderTypes.h"
#include <QString>
#include <QVector>
class ReminderStore
{
public:
QVector<ReminderItem> load(QString *errorMessage = nullptr) const;
bool save(const QVector<ReminderItem> &items, QString *errorMessage = nullptr) const;
QString storePath() const;
private:
QString configDirectoryPath() const;
void backupBrokenStore(const QString &filePath) const;
};
+155
View File
@@ -0,0 +1,155 @@
#include "ReminderTypes.h"
#include <algorithm>
namespace
{
QString twoDigit(int value)
{
return QString::number(value).rightJustified(2, QLatin1Char('0'));
}
QString weekdayDisplayText(int weekday)
{
switch (weekday)
{
case 1:
return QStringLiteral("一");
case 2:
return QStringLiteral("二");
case 3:
return QStringLiteral("三");
case 4:
return QStringLiteral("四");
case 5:
return QStringLiteral("五");
case 6:
return QStringLiteral("六");
case 7:
return QStringLiteral("日");
default:
return QStringLiteral("?");
}
}
QString recurrenceTimeText(const ReminderItem &item)
{
const int hour = item.recurrence.hour >= 0 ? item.recurrence.hour : item.remindAt.time().hour();
const int minute = item.recurrence.minute >= 0 ? item.recurrence.minute : item.remindAt.time().minute();
return QStringLiteral("%1:%2").arg(twoDigit(hour), twoDigit(minute));
}
}
QString reminderStatusToString(ReminderStatus status)
{
switch (status)
{
case ReminderStatus::Pending:
return QStringLiteral("pending");
case ReminderStatus::Triggered:
return QStringLiteral("triggered");
case ReminderStatus::Canceled:
return QStringLiteral("canceled");
}
return QStringLiteral("pending");
}
ReminderStatus reminderStatusFromString(const QString &status)
{
const QString normalized = status.trimmed().toLower();
if (normalized == QStringLiteral("triggered"))
{
return ReminderStatus::Triggered;
}
if (normalized == QStringLiteral("canceled"))
{
return ReminderStatus::Canceled;
}
return ReminderStatus::Pending;
}
QString reminderRecurrenceTypeToString(ReminderRecurrenceType type)
{
switch (type)
{
case ReminderRecurrenceType::None:
return QStringLiteral("none");
case ReminderRecurrenceType::Daily:
return QStringLiteral("daily");
case ReminderRecurrenceType::Weekly:
return QStringLiteral("weekly");
case ReminderRecurrenceType::Monthly:
return QStringLiteral("monthly");
}
return QStringLiteral("none");
}
ReminderRecurrenceType reminderRecurrenceTypeFromString(const QString &type)
{
const QString normalized = type.trimmed().toLower();
if (normalized == QStringLiteral("daily"))
{
return ReminderRecurrenceType::Daily;
}
if (normalized == QStringLiteral("weekly"))
{
return ReminderRecurrenceType::Weekly;
}
if (normalized == QStringLiteral("monthly"))
{
return ReminderRecurrenceType::Monthly;
}
return ReminderRecurrenceType::None;
}
bool reminderIsRecurring(const ReminderItem &item)
{
return reminderIsRecurring(item.recurrence);
}
bool reminderIsRecurring(const ReminderRecurrence &recurrence)
{
return recurrence.type != ReminderRecurrenceType::None;
}
QString reminderDisplayTime(const QDateTime &dateTime)
{
return dateTime.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm"));
}
QString reminderRecurrenceDisplayText(const ReminderItem &item)
{
switch (item.recurrence.type)
{
case ReminderRecurrenceType::None:
return QStringLiteral("一次");
case ReminderRecurrenceType::Daily:
return QStringLiteral("每天 %1").arg(recurrenceTimeText(item));
case ReminderRecurrenceType::Weekly:
return QStringLiteral("每周%1 %2").arg(weekdayDisplayText(item.recurrence.weekday), recurrenceTimeText(item));
case ReminderRecurrenceType::Monthly:
return QStringLiteral("每月%1日 %2").arg(item.recurrence.monthDay).arg(recurrenceTimeText(item));
}
return QStringLiteral("一次");
}
QVector<ReminderItem> sortedReminders(QVector<ReminderItem> reminders)
{
std::sort(reminders.begin(), reminders.end(), [](const ReminderItem &left, const ReminderItem &right) {
if (left.remindAt == right.remindAt)
{
return left.createdAt < right.createdAt;
}
return left.remindAt < right.remindAt;
});
return reminders;
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <QDateTime>
#include <QString>
#include <QVector>
enum class ReminderStatus
{
Pending,
Triggered,
Canceled,
};
enum class ReminderCommandType
{
Create,
List,
Cancel,
Invalid,
};
enum class ReminderRecurrenceType
{
None,
Daily,
Weekly,
Monthly,
};
struct ReminderRecurrence
{
ReminderRecurrenceType type = ReminderRecurrenceType::None;
int interval = 1;
int weekday = 0;
int monthDay = 0;
int hour = -1;
int minute = -1;
QDateTime lastTriggeredAt;
};
struct ReminderItem
{
QString id;
QString title;
QString originalText;
QDateTime remindAt;
ReminderStatus status = ReminderStatus::Pending;
QDateTime createdAt;
QDateTime finishedAt;
QString soundId;
ReminderRecurrence recurrence;
};
struct ReminderCommand
{
ReminderCommandType type = ReminderCommandType::Invalid;
QString title;
QString originalText;
QDateTime remindAt;
QString cancelQuery;
QString errorMessage;
ReminderRecurrence recurrence;
};
QString reminderStatusToString(ReminderStatus status);
ReminderStatus reminderStatusFromString(const QString &status);
QString reminderRecurrenceTypeToString(ReminderRecurrenceType type);
ReminderRecurrenceType reminderRecurrenceTypeFromString(const QString &type);
bool reminderIsRecurring(const ReminderItem &item);
bool reminderIsRecurring(const ReminderRecurrence &recurrence);
QString reminderDisplayTime(const QDateTime &dateTime);
QString reminderRecurrenceDisplayText(const ReminderItem &item);
QVector<ReminderItem> sortedReminders(QVector<ReminderItem> reminders);
+76
View File
@@ -0,0 +1,76 @@
#include "StartupManager.h"
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QSettings>
namespace
{
const QString RunRegistryPath = QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run");
const QString StartupEntryName = QStringLiteral("QtDesktopPet");
QString quotedCommand(const QString &filePath)
{
QString nativePath = QDir::toNativeSeparators(filePath.trimmed());
nativePath.replace(QLatin1Char('"'), QStringLiteral("\\\""));
return QStringLiteral("\"") + nativePath + QStringLiteral("\"");
}
}
namespace StartupManager
{
QString startupEntryName()
{
return StartupEntryName;
}
QString startupCommandForCurrentExecutable()
{
const QString executablePath = QFileInfo(QCoreApplication::applicationFilePath()).absoluteFilePath();
return executablePath.trimmed().isEmpty() ? QString() : quotedCommand(executablePath);
}
bool isLaunchAtStartupEnabled()
{
QSettings settings(RunRegistryPath, QSettings::NativeFormat);
return !settings.value(StartupEntryName).toString().trimmed().isEmpty();
}
bool setLaunchAtStartupEnabled(bool enabled, QString *errorMessage)
{
QSettings settings(RunRegistryPath, QSettings::NativeFormat);
if (enabled)
{
const QString command = startupCommandForCurrentExecutable();
if (command.isEmpty())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法获取当前程序路径,开机自启动设置未保存。");
}
return false;
}
settings.setValue(StartupEntryName, command);
}
else
{
settings.remove(StartupEntryName);
}
settings.sync();
if (settings.status() != QSettings::NoError)
{
if (errorMessage != nullptr)
{
*errorMessage = enabled
? QStringLiteral("写入开机自启动注册表失败。")
: QStringLiteral("移除开机自启动注册表失败。");
}
return false;
}
return true;
}
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <QString>
namespace StartupManager
{
QString startupEntryName();
QString startupCommandForCurrentExecutable();
bool isLaunchAtStartupEnabled();
bool setLaunchAtStartupEnabled(bool enabled, QString *errorMessage = nullptr);
}
+11
View File
@@ -62,6 +62,17 @@ void TrayController::show()
m_trayIcon.show(); m_trayIcon.show();
} }
bool TrayController::showNotification(const QString &title, const QString &message)
{
if (!isAvailable() || !m_trayIcon.isVisible() || !QSystemTrayIcon::supportsMessages())
{
return false;
}
m_trayIcon.showMessage(title, message, QSystemTrayIcon::Information, 10000);
return true;
}
void TrayController::createMenu() void TrayController::createMenu()
{ {
QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠")); QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠"));
+1
View File
@@ -12,6 +12,7 @@ public:
bool isAvailable() const; bool isAvailable() const;
void show(); void show();
bool showNotification(const QString &title, const QString &message);
private: private:
void createMenu(); void createMenu();
+1 -43
View File
@@ -1,11 +1,11 @@
#include "ChatBubble.h" #include "ChatBubble.h"
#include <QApplication>
#include <QEvent> #include <QEvent>
#include <QFrame> #include <QFrame>
#include <QRect> #include <QRect>
#include <QScrollBar> #include <QScrollBar>
#include <QSize> #include <QSize>
#include <QTextEdit>
#include <QTextDocument> #include <QTextDocument>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QtGlobal> #include <QtGlobal>
@@ -48,7 +48,6 @@ ChatBubble::ChatBubble(QWidget *parent)
m_textEdit->installEventFilter(this); m_textEdit->installEventFilter(this);
m_textEdit->viewport()->installEventFilter(this); m_textEdit->viewport()->installEventFilter(this);
m_textEdit->verticalScrollBar()->installEventFilter(this); m_textEdit->verticalScrollBar()->installEventFilter(this);
qApp->installEventFilter(this);
m_textEdit->setStyleSheet(QStringLiteral( m_textEdit->setStyleSheet(QStringLiteral(
"QTextEdit {" "QTextEdit {"
"background: rgba(255, 255, 255, 232);" "background: rgba(255, 255, 255, 232);"
@@ -80,17 +79,8 @@ ChatBubble::ChatBubble(QWidget *parent)
}); });
} }
ChatBubble::~ChatBubble()
{
if (qApp != nullptr)
{
qApp->removeEventFilter(this);
}
}
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom) void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom)
{ {
m_dismissOnExternalInteraction = false;
m_anchorPosition = anchorPosition; m_anchorPosition = anchorPosition;
m_autoHideDurationMs = durationMs; m_autoHideDurationMs = durationMs;
const QString trimmed = message.trimmed(); const QString trimmed = message.trimmed();
@@ -129,11 +119,6 @@ void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition)
} }
} }
void ChatBubble::setDismissOnExternalInteraction(bool enabled)
{
m_dismissOnExternalInteraction = enabled;
}
void ChatBubble::resetAutoHideTimer() void ChatBubble::resetAutoHideTimer()
{ {
if (isVisible() && m_autoHideDurationMs > 0) if (isVisible() && m_autoHideDurationMs > 0)
@@ -150,18 +135,6 @@ void ChatBubble::hideBubble()
bool ChatBubble::eventFilter(QObject *watched, QEvent *event) bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
{ {
if (watched == qApp && m_dismissOnExternalInteraction && event->type() == QEvent::ApplicationDeactivate)
{
hideBubble();
return false;
}
if (m_dismissOnExternalInteraction && event->type() == QEvent::MouseButtonPress && !isOwnObject(watched))
{
hideBubble();
return false;
}
if ((watched == m_textEdit || watched == m_textEdit->viewport() || watched == m_textEdit->verticalScrollBar()) if ((watched == m_textEdit || watched == m_textEdit->viewport() || watched == m_textEdit->verticalScrollBar())
&& isUserInteractionEvent(event)) && isUserInteractionEvent(event))
{ {
@@ -171,21 +144,6 @@ bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
return QWidget::eventFilter(watched, event); return QWidget::eventFilter(watched, event);
} }
bool ChatBubble::isOwnObject(QObject *object) const
{
while (object != nullptr)
{
if (object == this || object == m_textEdit || object == m_textEdit->viewport() || object == m_textEdit->verticalScrollBar())
{
return true;
}
object = object->parent();
}
return false;
}
bool ChatBubble::isUserInteractionEvent(QEvent *event) const bool ChatBubble::isUserInteractionEvent(QEvent *event) const
{ {
switch (event->type()) switch (event->type())
+1 -5
View File
@@ -1,16 +1,15 @@
#pragma once #pragma once
#include <QTextEdit>
#include <QTimer> #include <QTimer>
#include <QWidget> #include <QWidget>
class QEvent; class QEvent;
class QTextEdit;
class ChatBubble : public QWidget class ChatBubble : public QWidget
{ {
public: public:
explicit ChatBubble(QWidget *parent = nullptr); explicit ChatBubble(QWidget *parent = nullptr);
~ChatBubble() override;
void showMessage( void showMessage(
const QString &message, const QString &message,
@@ -18,7 +17,6 @@ public:
int durationMs = 10000, int durationMs = 10000,
bool scrollToBottom = false); bool scrollToBottom = false);
void updateAnchorPosition(const QPoint &anchorPosition); void updateAnchorPosition(const QPoint &anchorPosition);
void setDismissOnExternalInteraction(bool enabled);
void resetAutoHideTimer(); void resetAutoHideTimer();
void hideBubble(); void hideBubble();
@@ -26,7 +24,6 @@ protected:
bool eventFilter(QObject *watched, QEvent *event) override; bool eventFilter(QObject *watched, QEvent *event) override;
private: private:
bool isOwnObject(QObject *object) const;
bool isUserInteractionEvent(QEvent *event) const; bool isUserInteractionEvent(QEvent *event) const;
QSize preferredBubbleSize(const QString &message) const; QSize preferredBubbleSize(const QString &message) const;
void updatePosition(); void updatePosition();
@@ -35,5 +32,4 @@ private:
QTimer m_hideTimer; QTimer m_hideTimer;
QPoint m_anchorPosition; QPoint m_anchorPosition;
int m_autoHideDurationMs = 0; int m_autoHideDurationMs = 0;
bool m_dismissOnExternalInteraction = false;
}; };
+75 -1
View File
@@ -1,6 +1,7 @@
#include "ChatInputDialog.h" #include "ChatInputDialog.h"
#include <QApplication> #include <QApplication>
#include <QCheckBox>
#include <QEvent> #include <QEvent>
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
@@ -8,6 +9,7 @@
#include <QLabel> #include <QLabel>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPushButton> #include <QPushButton>
#include <QSignalBlocker>
#include <QTextEdit> #include <QTextEdit>
#include <utility> #include <utility>
@@ -16,9 +18,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
: QDialog(parent) : QDialog(parent)
, m_textEdit(new QTextEdit(this)) , m_textEdit(new QTextEdit(this))
, m_counterLabel(new QLabel(this)) , m_counterLabel(new QLabel(this))
, m_webToggleCheckBox(new QCheckBox(QStringLiteral("联网"), this))
, m_sendButton(new QPushButton(QStringLiteral("↗"), this)) , m_sendButton(new QPushButton(QStringLiteral("↗"), this))
, m_maxLength(maxLength) , m_maxLength(maxLength)
{ {
m_webToggleCheckBox->setObjectName(QStringLiteral("WebToggle"));
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_TranslucentBackground);
setModal(false); setModal(false);
@@ -59,6 +64,27 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
"QPushButton:disabled {" "QPushButton:disabled {"
"color: rgba(32, 33, 36, 80);" "color: rgba(32, 33, 36, 80);"
"background: rgba(255, 255, 255, 170);" "background: rgba(255, 255, 255, 170);"
"}"
"QCheckBox#WebToggle {"
"color: #202124;"
"font-size: 13px;"
"font-weight: 600;"
"spacing: 5px;"
"}"
"QCheckBox#WebToggle::indicator {"
"width: 16px;"
"height: 16px;"
"border: 1px solid rgba(32, 33, 36, 70);"
"border-radius: 8px;"
"background: rgba(255, 255, 255, 235);"
"}"
"QCheckBox#WebToggle::indicator:checked {"
"background: #175cd3;"
"border: 1px solid #175cd3;"
"}"
"QCheckBox#WebToggle::indicator:disabled {"
"background: rgba(255, 255, 255, 120);"
"border: 1px solid rgba(32, 33, 36, 28);"
"}")); "}"));
m_textEdit->setPlaceholderText(QStringLiteral("输入消息,Enter 发送,Shift+Enter 换行")); m_textEdit->setPlaceholderText(QStringLiteral("输入消息,Enter 发送,Shift+Enter 换行"));
@@ -73,12 +99,20 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
m_counterLabel->setStyleSheet(QStringLiteral("color: #b3261e; font-size: 12px;")); m_counterLabel->setStyleSheet(QStringLiteral("color: #b3261e; font-size: 12px;"));
m_counterLabel->setVisible(false); m_counterLabel->setVisible(false);
m_webToggleCheckBox->setToolTip(QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。"));
connect(m_sendButton, &QPushButton::clicked, this, [this]() { connect(m_sendButton, &QPushButton::clicked, this, [this]() {
submitIfValid(); submitIfValid();
}); });
connect(m_textEdit, &QTextEdit::textChanged, this, [this]() { connect(m_textEdit, &QTextEdit::textChanged, this, [this]() {
updateCounter(); updateCounter();
}); });
connect(m_webToggleCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
if (m_webToggleChangedCallback)
{
m_webToggleChangedCallback(checked);
}
});
auto *panel = new QFrame(this); auto *panel = new QFrame(this);
panel->setObjectName(QStringLiteral("InputPanel")); panel->setObjectName(QStringLiteral("InputPanel"));
@@ -89,6 +123,7 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
panelLayout->setSpacing(8); panelLayout->setSpacing(8);
panelLayout->addWidget(m_textEdit, 1); panelLayout->addWidget(m_textEdit, 1);
panelLayout->addWidget(m_counterLabel); panelLayout->addWidget(m_counterLabel);
panelLayout->addWidget(m_webToggleCheckBox);
panelLayout->addWidget(m_sendButton); panelLayout->addWidget(m_sendButton);
auto *layout = new QHBoxLayout(this); auto *layout = new QHBoxLayout(this);
@@ -104,11 +139,50 @@ QString ChatInputDialog::message() const
return m_textEdit->toPlainText().trimmed(); return m_textEdit->toPlainText().trimmed();
} }
bool ChatInputDialog::webEnabled() const
{
return m_webToggleCheckBox != nullptr
&& m_webToggleCheckBox->isEnabled()
&& m_webToggleCheckBox->isChecked();
}
void ChatInputDialog::setWebEnabled(bool enabled)
{
if (m_webToggleCheckBox)
{
const QSignalBlocker blocker(m_webToggleCheckBox);
m_webToggleCheckBox->setChecked(enabled);
}
}
void ChatInputDialog::setWebToggleAvailable(bool available, const QString &toolTip)
{
if (!m_webToggleCheckBox)
{
return;
}
const QSignalBlocker blocker(m_webToggleCheckBox);
m_webToggleCheckBox->setEnabled(available);
if (!available)
{
m_webToggleCheckBox->setChecked(false);
}
m_webToggleCheckBox->setToolTip(toolTip.trimmed().isEmpty()
? QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。")
: toolTip);
}
void ChatInputDialog::setSubmitCallback(SubmitCallback callback) void ChatInputDialog::setSubmitCallback(SubmitCallback callback)
{ {
m_submitCallback = std::move(callback); m_submitCallback = std::move(callback);
} }
void ChatInputDialog::setWebToggleChangedCallback(WebToggleChangedCallback callback)
{
m_webToggleChangedCallback = std::move(callback);
}
void ChatInputDialog::showAt(const QPoint &anchorPosition) void ChatInputDialog::showAt(const QPoint &anchorPosition)
{ {
move(anchorPosition.x() - width() / 2, anchorPosition.y() - height() / 2); move(anchorPosition.x() - width() / 2, anchorPosition.y() - height() / 2);
@@ -217,7 +291,7 @@ bool ChatInputDialog::submitIfValid()
} }
const QString submittedMessage = message(); const QString submittedMessage = message();
const bool accepted = m_submitCallback ? m_submitCallback(submittedMessage) : true; const bool accepted = m_submitCallback ? m_submitCallback(submittedMessage, webEnabled()) : true;
if (accepted) if (accepted)
{ {
clearMessage(); clearMessage();
+9 -1
View File
@@ -7,6 +7,7 @@
#include <functional> #include <functional>
class QEvent; class QEvent;
class QCheckBox;
class QLabel; class QLabel;
class QMouseEvent; class QMouseEvent;
class QPushButton; class QPushButton;
@@ -15,12 +16,17 @@ class QTextEdit;
class ChatInputDialog : public QDialog class ChatInputDialog : public QDialog
{ {
public: public:
using SubmitCallback = std::function<bool(const QString &)>; using SubmitCallback = std::function<bool(const QString &, bool)>;
using WebToggleChangedCallback = std::function<void(bool)>;
explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr); explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr);
QString message() const; QString message() const;
bool webEnabled() const;
void setWebEnabled(bool enabled);
void setWebToggleAvailable(bool available, const QString &toolTip = QString());
void setSubmitCallback(SubmitCallback callback); void setSubmitCallback(SubmitCallback callback);
void setWebToggleChangedCallback(WebToggleChangedCallback callback);
void showAt(const QPoint &anchorPosition); void showAt(const QPoint &anchorPosition);
protected: protected:
@@ -41,8 +47,10 @@ private:
QTextEdit *m_textEdit = nullptr; QTextEdit *m_textEdit = nullptr;
QLabel *m_counterLabel = nullptr; QLabel *m_counterLabel = nullptr;
QCheckBox *m_webToggleCheckBox = nullptr;
QPushButton *m_sendButton = nullptr; QPushButton *m_sendButton = nullptr;
SubmitCallback m_submitCallback; SubmitCallback m_submitCallback;
WebToggleChangedCallback m_webToggleChangedCallback;
QPoint m_dragStartPosition; QPoint m_dragStartPosition;
QPoint m_dragWindowPosition; QPoint m_dragWindowPosition;
int m_maxLength = 0; int m_maxLength = 0;
+832 -23
View File
File diff suppressed because it is too large Load Diff
+45 -1
View File
@@ -4,16 +4,21 @@
#include "../character/CharacterPackage.h" #include "../character/CharacterPackage.h"
#include "../character/FrameAnimator.h" #include "../character/FrameAnimator.h"
#include "../config/AppConfig.h" #include "../config/AppConfig.h"
#include "../reminder/ReminderTypes.h"
#include "../web/WebChatTypes.h"
#include "../state/PetStateMachine.h" #include "../state/PetStateMachine.h"
#include <QMap> #include <QMap>
#include <QPoint> #include <QPoint>
#include <QRect>
#include <QSet> #include <QSet>
#include <QStringList> #include <QStringList>
#include <QTimer> #include <QTimer>
#include <QVector>
#include <QWidget> #include <QWidget>
#include <QtGlobal> #include <QtGlobal>
#include <functional>
#include <memory> #include <memory>
class QMenu; class QMenu;
@@ -26,7 +31,12 @@ class ChatHistoryPanel;
class ChatInputDialog; class ChatInputDialog;
class ConversationManager; class ConversationManager;
class ConversationStore; class ConversationStore;
class NotificationDispatcher;
class PetView; class PetView;
class ReminderManager;
class ReminderSoundPlayer;
class WebChatManager;
class WeatherManager;
class PetWindow : public QWidget class PetWindow : public QWidget
{ {
@@ -39,6 +49,7 @@ public:
void openSettingsDialog(); void openSettingsDialog();
void activateFromExternalInstance(); void activateFromExternalInstance();
void setSettingsFallbackInContextMenuEnabled(bool enabled); void setSettingsFallbackInContextMenuEnabled(bool enabled);
void setTrayNotificationCallback(std::function<bool(const QString &, const QString &)> callback);
void pauseAnimation(); void pauseAnimation();
void resumeAnimation(); void resumeAnimation();
void showBubbleMessage(const QString &message); void showBubbleMessage(const QString &message);
@@ -59,7 +70,28 @@ private:
void buildAnimationClips(); void buildAnimationClips();
void addStateTestActions(QMenu *menu); void addStateTestActions(QMenu *menu);
void startChat(); void startChat();
bool submitChatMessage(const QString &message); bool submitChatMessage(const QString &message, bool webEnabled);
bool submitAiChatMessage(const QString &message);
bool submitWebChatMessage(const QString &message);
bool handleReminderChatMessage(const QString &message);
bool handleWeatherChatMessage(const QString &message);
bool hasActiveWebRequest() const;
QString formatWebChatResponseForDisplay(const WebChatResponse &response) const;
void refreshChatInputWebToggle();
void saveWebTogglePreference(bool webEnabled);
void handleTriggeredReminder(const ReminderItem &item);
void playReminderSound();
void showReminderNotification(const ReminderItem &item);
bool shouldNotifyOnlyForReminder() const;
void enqueueVisibleTriggeredReminder(const ReminderItem &item);
void showNextTriggeredReminder();
void finishActiveTriggeredReminder(bool hideBubble);
void showTriggeredReminder(const ReminderItem &item);
void ensureReminderActionPanel();
void showReminderActions(const ReminderItem &item);
void hideReminderActions();
void updateReminderActionPosition();
void snoozeTriggeredReminder(const ReminderItem &item);
void clearConversation(); void clearConversation();
void cancelActiveAIRequest(); void cancelActiveAIRequest();
void showConversationHistory(); void showConversationHistory();
@@ -77,6 +109,7 @@ private:
QPoint chatInputAnchorPosition() const; QPoint chatInputAnchorPosition() const;
void updateBubblePosition(); void updateBubblePosition();
QPoint bubbleAnchorPosition() const; QPoint bubbleAnchorPosition() const;
QRect visiblePetGeometry() const;
void playState(const QString &stateName, bool centerWindow); void playState(const QString &stateName, bool centerWindow);
void playResolvedState(const QString &stateName, bool centerWindow); void playResolvedState(const QString &stateName, bool centerWindow);
QSize animationTargetSize() const; QSize animationTargetSize() const;
@@ -107,6 +140,12 @@ private:
std::unique_ptr<ChatInputDialog> m_chatInputDialog; std::unique_ptr<ChatInputDialog> m_chatInputDialog;
std::unique_ptr<ConversationManager> m_conversationManager; std::unique_ptr<ConversationManager> m_conversationManager;
std::unique_ptr<ConversationStore> m_conversationStore; std::unique_ptr<ConversationStore> m_conversationStore;
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
std::unique_ptr<ReminderManager> m_reminderManager;
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
std::unique_ptr<WebChatManager> m_webChatManager;
std::unique_ptr<WeatherManager> m_weatherManager;
std::unique_ptr<QWidget> m_reminderActionPanel;
PetView *m_petView; PetView *m_petView;
QTimer m_idleBehaviorTimer; QTimer m_idleBehaviorTimer;
QTimer m_behaviorReturnTimer; QTimer m_behaviorReturnTimer;
@@ -122,6 +161,8 @@ private:
QPoint m_dragOffset; QPoint m_dragOffset;
QString m_streamingAssistantText; QString m_streamingAssistantText;
QStringList m_animationPrewarmQueue; QStringList m_animationPrewarmQueue;
QVector<ReminderItem> m_pendingVisibleTriggeredReminders;
ReminderItem m_activeTriggeredReminder;
qint64 m_clipAccessSerial = 0; qint64 m_clipAccessSerial = 0;
bool m_dragging; bool m_dragging;
bool m_alwaysOnTop; bool m_alwaysOnTop;
@@ -130,4 +171,7 @@ private:
bool m_streamingChatActive = false; bool m_streamingChatActive = false;
bool m_streamingTalkStarted = false; bool m_streamingTalkStarted = false;
bool m_settingsFallbackInContextMenuEnabled = true; bool m_settingsFallbackInContextMenuEnabled = true;
bool m_hasActiveTriggeredReminder = false;
mutable qint64 m_visiblePetRectPixmapKey = 0;
mutable QRect m_visiblePetRectCache;
}; };
+1442 -2
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -2,8 +2,13 @@
#include "../config/AIConfig.h" #include "../config/AIConfig.h"
#include "../config/AppConfig.h" #include "../config/AppConfig.h"
#include "../ai/LLMTypes.h"
#include "../reminder/ReminderTypes.h"
#include "../web/WebConfig.h"
#include "../weather/WeatherConfig.h"
#include <QDialog> #include <QDialog>
#include <QVector>
#include <functional> #include <functional>
#include <memory> #include <memory>
@@ -13,9 +18,12 @@ class QComboBox;
class QDoubleSpinBox; class QDoubleSpinBox;
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QListWidget;
class QPushButton; class QPushButton;
class QSpinBox; class QSpinBox;
class LLMProvider; class LLMProvider;
class WebChatManager;
class WeatherManager;
class SettingsDialog : public QDialog class SettingsDialog : public QDialog
{ {
@@ -23,13 +31,23 @@ public:
explicit SettingsDialog( explicit SettingsDialog(
const AIConfigStore &configStore, const AIConfigStore &configStore,
const AppConfig &appConfig, const AppConfig &appConfig,
const WeatherConfig &weatherConfig,
const WebConfig &webConfig,
const QVector<ChatMessage> &conversationHistory,
const QVector<ReminderItem> &reminders,
std::function<bool()> aiTestBlocked, std::function<bool()> aiTestBlocked,
std::function<void()> clearConversationHistoryCallback, std::function<void()> clearConversationHistoryCallback,
std::function<bool(const QString &, QString *)> cancelReminderCallback,
std::function<bool(const QString &, const QString &, const QDateTime &, const ReminderRecurrence &, ReminderItem *, QString *)> updateReminderCallback,
std::function<bool(QString *)> clearFinishedRemindersCallback,
std::function<void(const QString &, double)> playReminderSoundCallback,
QWidget *parent = nullptr); QWidget *parent = nullptr);
~SettingsDialog() override; ~SettingsDialog() override;
AIConfigStore aiConfigStore() const; AIConfigStore aiConfigStore() const;
AppConfig appConfig() const; AppConfig appConfig() const;
WeatherConfig weatherConfig() const;
WebConfig webConfig() const;
protected: protected:
void accept() override; void accept() override;
@@ -45,9 +63,29 @@ private:
void testConnection(); void testConnection();
void setTestStatus(const QString &message, const QString &state); void setTestStatus(const QString &message, const QString &state);
void clearConversationHistory(); void clearConversationHistory();
void reloadConversationHistoryList();
QVector<ChatMessage> filteredConversationHistory() const;
void exportConversationHistoryMarkdown();
void exportConversationHistoryJson();
void reloadCharacterList(const QString &selectedCharacterId = {}); void reloadCharacterList(const QString &selectedCharacterId = {});
void importCharacterFolder(); void importCharacterFolder();
void deleteSelectedCharacter(); void deleteSelectedCharacter();
void exportSelectedCharacter();
void openUserCharacterDirectory();
void reloadReminderList();
void reloadReminderSoundList(const QString &selectedSoundId = {});
QString selectedReminderSoundId() const;
void updateReminderSoundButtons();
void updateReminderActionButtons();
void cancelSelectedReminder();
void editSelectedReminder();
void clearFinishedReminders();
void importReminderSound();
void deleteSelectedReminderSound();
void testSelectedReminderSound();
void testWeatherDefaultCity();
void refreshWebCapabilityStatus();
void testWebMode();
QComboBox *m_providerComboBox = nullptr; QComboBox *m_providerComboBox = nullptr;
QLineEdit *m_baseUrlEdit = nullptr; QLineEdit *m_baseUrlEdit = nullptr;
@@ -60,6 +98,7 @@ private:
QPushButton *m_testConnectionButton = nullptr; QPushButton *m_testConnectionButton = nullptr;
QLabel *m_testStatusLabel = nullptr; QLabel *m_testStatusLabel = nullptr;
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr; QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
QCheckBox *m_launchAtStartupCheckBox = nullptr;
QSpinBox *m_scaleSpinBox = nullptr; QSpinBox *m_scaleSpinBox = nullptr;
QComboBox *m_performanceModeComboBox = nullptr; QComboBox *m_performanceModeComboBox = nullptr;
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr; QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
@@ -73,16 +112,60 @@ private:
QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr; QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr;
QPushButton *m_clearConversationHistoryButton = nullptr; QPushButton *m_clearConversationHistoryButton = nullptr;
QLabel *m_clearConversationStatusLabel = nullptr; QLabel *m_clearConversationStatusLabel = nullptr;
QLineEdit *m_historySearchEdit = nullptr;
QComboBox *m_historyProviderFilterComboBox = nullptr;
QComboBox *m_historyModelFilterComboBox = nullptr;
QListWidget *m_historyListWidget = nullptr;
QPushButton *m_exportHistoryMarkdownButton = nullptr;
QPushButton *m_exportHistoryJsonButton = nullptr;
QLineEdit *m_weatherDefaultCityEdit = nullptr;
QCheckBox *m_weatherAutoLocateCheckBox = nullptr;
QPushButton *m_testWeatherDefaultCityButton = nullptr;
QLabel *m_weatherStatusLabel = nullptr;
QLabel *m_webCapabilityStatusLabel = nullptr;
QCheckBox *m_webEnabledCheckBox = nullptr;
QCheckBox *m_webRememberToggleCheckBox = nullptr;
QCheckBox *m_webDefaultOnCheckBox = nullptr;
QComboBox *m_webProviderModeComboBox = nullptr;
QSpinBox *m_webTimeoutSpinBox = nullptr;
QCheckBox *m_webShowCitationsCheckBox = nullptr;
QPushButton *m_testWebButton = nullptr;
QLabel *m_webStatusLabel = nullptr;
QComboBox *m_characterComboBox = nullptr; QComboBox *m_characterComboBox = nullptr;
QPushButton *m_importCharacterButton = nullptr; QPushButton *m_importCharacterButton = nullptr;
QPushButton *m_deleteCharacterButton = nullptr; QPushButton *m_deleteCharacterButton = nullptr;
QPushButton *m_exportCharacterButton = nullptr;
QPushButton *m_openUserCharacterDirButton = nullptr;
QLabel *m_characterStatusLabel = nullptr; QLabel *m_characterStatusLabel = nullptr;
QComboBox *m_reminderStatusFilterComboBox = nullptr;
QListWidget *m_reminderListWidget = nullptr;
QPushButton *m_cancelReminderButton = nullptr;
QPushButton *m_editReminderButton = nullptr;
QPushButton *m_clearFinishedRemindersButton = nullptr;
QLabel *m_reminderStatusLabel = nullptr;
QCheckBox *m_reminderSoundEnabledCheckBox = nullptr;
QSpinBox *m_reminderSoundVolumeSpinBox = nullptr;
QComboBox *m_reminderSoundComboBox = nullptr;
QPushButton *m_importReminderSoundButton = nullptr;
QPushButton *m_deleteReminderSoundButton = nullptr;
QPushButton *m_testReminderSoundButton = nullptr;
QLabel *m_reminderSoundStatusLabel = nullptr;
AIConfigStore m_configStore; AIConfigStore m_configStore;
AIConfigStore m_acceptedConfigStore; AIConfigStore m_acceptedConfigStore;
AppConfig m_appConfig; AppConfig m_appConfig;
WeatherConfig m_weatherConfig;
WebConfig m_webConfig;
QVector<ChatMessage> m_conversationHistory;
QVector<ReminderItem> m_reminders;
QString m_currentProvider; QString m_currentProvider;
std::function<bool()> m_aiTestBlocked; std::function<bool()> m_aiTestBlocked;
std::function<void()> m_clearConversationHistory; std::function<void()> m_clearConversationHistory;
std::function<bool(const QString &, QString *)> m_cancelReminder;
std::function<bool(const QString &, const QString &, const QDateTime &, const ReminderRecurrence &, ReminderItem *, QString *)> m_updateReminder;
std::function<bool(QString *)> m_clearFinishedReminders;
std::function<void(const QString &, double)> m_playReminderSound;
std::unique_ptr<LLMProvider> m_testProvider; std::unique_ptr<LLMProvider> m_testProvider;
std::unique_ptr<WebChatManager> m_webTestManager;
std::unique_ptr<WeatherManager> m_weatherTestManager;
bool m_hasAcceptedConfigStore = false; bool m_hasAcceptedConfigStore = false;
}; };
+5 -16
View File
@@ -27,17 +27,6 @@ QString sourceResourcesRootPath()
} }
} }
QString ResourcePaths::resourcesRootPath()
{
const QString appResources = appResourcesRootPath();
if (QFileInfo::exists(appResources))
{
return appResources;
}
return sourceResourcesRootPath();
}
QString ResourcePaths::resourcePath(const QString &relativePath) QString ResourcePaths::resourcePath(const QString &relativePath)
{ {
const QString cleanedRelativePath = cleanRelativePath(relativePath); const QString cleanedRelativePath = cleanRelativePath(relativePath);
@@ -55,12 +44,12 @@ QString ResourcePaths::charactersRootPath()
return resourcePath(QStringLiteral("characters")); return resourcePath(QStringLiteral("characters"));
} }
QString ResourcePaths::reminderSoundsRootPath()
{
return resourcePath(QStringLiteral("sounds/reminders"));
}
QString ResourcePaths::appIconPath() QString ResourcePaths::appIconPath()
{ {
return resourcePath(QStringLiteral("icons/app_icon.ico")); return resourcePath(QStringLiteral("icons/app_icon.ico"));
} }
QString ResourcePaths::appIconSourcePngPath()
{
return resourcePath(QStringLiteral("icons/app_icon_1024.png"));
}
+1 -2
View File
@@ -5,9 +5,8 @@
class ResourcePaths class ResourcePaths
{ {
public: public:
static QString resourcesRootPath();
static QString resourcePath(const QString &relativePath); static QString resourcePath(const QString &relativePath);
static QString charactersRootPath(); static QString charactersRootPath();
static QString reminderSoundsRootPath();
static QString appIconPath(); static QString appIconPath();
static QString appIconSourcePngPath();
}; };
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QString>
struct WeatherConfig
{
QString provider = QStringLiteral("open-meteo");
QString defaultCityName;
bool autoLocateWhenNoDefault = true;
QString language = QStringLiteral("zh");
int timeoutMs = 10000;
};
+390
View File
@@ -0,0 +1,390 @@
#include "WeatherLocationResolver.h"
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QRegularExpression>
#include <QSet>
#include <QStringList>
#include <QtGlobal>
namespace
{
struct CityAlias
{
QString queryName;
QString countryCode;
};
QString compactKey(QString text)
{
text = text.trimmed().toLower();
text.remove(QRegularExpression(QStringLiteral("[\\s,,、。.]+")));
return text;
}
QString cityAliasKey(QString text)
{
text = compactKey(text);
if (text.size() > 2 && text.endsWith(QStringLiteral("市")))
{
text.chop(1);
}
return text;
}
const QVector<QPair<QString, QString>> &countryAliases()
{
static const QVector<QPair<QString, QString>> aliases = {
{QStringLiteral("中华人民共和国"), QStringLiteral("CN")},
{QStringLiteral("United States"), QStringLiteral("US")},
{QStringLiteral("United Kingdom"), QStringLiteral("GB")},
{QStringLiteral("澳大利亚"), QStringLiteral("AU")},
{QStringLiteral("马来西亚"), QStringLiteral("MY")},
{QStringLiteral("印度尼西亚"), QStringLiteral("ID")},
{QStringLiteral("新西兰"), QStringLiteral("NZ")},
{QStringLiteral("新加坡"), QStringLiteral("SG")},
{QStringLiteral("加拿大"), QStringLiteral("CA")},
{QStringLiteral("菲律宾"), QStringLiteral("PH")},
{QStringLiteral("俄罗斯"), QStringLiteral("RU")},
{QStringLiteral("西班牙"), QStringLiteral("ES")},
{QStringLiteral("阿联酋"), QStringLiteral("AE")},
{QStringLiteral("土耳其"), QStringLiteral("TR")},
{QStringLiteral("墨西哥"), QStringLiteral("MX")},
{QStringLiteral("意大利"), QStringLiteral("IT")},
{QStringLiteral("Australia"), QStringLiteral("AU")},
{QStringLiteral("Malaysia"), QStringLiteral("MY")},
{QStringLiteral("Indonesia"), QStringLiteral("ID")},
{QStringLiteral("Singapore"), QStringLiteral("SG")},
{QStringLiteral("Canada"), QStringLiteral("CA")},
{QStringLiteral("Philippines"), QStringLiteral("PH")},
{QStringLiteral("Russia"), QStringLiteral("RU")},
{QStringLiteral("Germany"), QStringLiteral("DE")},
{QStringLiteral("France"), QStringLiteral("FR")},
{QStringLiteral("Britain"), QStringLiteral("GB")},
{QStringLiteral("England"), QStringLiteral("GB")},
{QStringLiteral("Japan"), QStringLiteral("JP")},
{QStringLiteral("中国"), QStringLiteral("CN")},
{QStringLiteral("美国"), QStringLiteral("US")},
{QStringLiteral("英国"), QStringLiteral("GB")},
{QStringLiteral("法国"), QStringLiteral("FR")},
{QStringLiteral("德国"), QStringLiteral("DE")},
{QStringLiteral("日本"), QStringLiteral("JP")},
{QStringLiteral("韩国"), QStringLiteral("KR")},
{QStringLiteral("印度"), QStringLiteral("IN")},
{QStringLiteral("泰国"), QStringLiteral("TH")},
{QStringLiteral("越南"), QStringLiteral("VN")},
{QStringLiteral("巴西"), QStringLiteral("BR")},
{QStringLiteral("埃及"), QStringLiteral("EG")},
{QStringLiteral("China"), QStringLiteral("CN")},
{QStringLiteral("USA"), QStringLiteral("US")},
{QStringLiteral("Korea"), QStringLiteral("KR")},
{QStringLiteral("India"), QStringLiteral("IN")},
{QStringLiteral("Thailand"), QStringLiteral("TH")},
{QStringLiteral("Vietnam"), QStringLiteral("VN")},
{QStringLiteral("Brazil"), QStringLiteral("BR")},
{QStringLiteral("Mexico"), QStringLiteral("MX")},
{QStringLiteral("Egypt"), QStringLiteral("EG")},
{QStringLiteral("Turkey"), QStringLiteral("TR")},
};
return aliases;
}
QString countryCodeForText(const QString &text)
{
const QString key = compactKey(text);
const auto isAsciiLetter = [](QChar character) {
const ushort value = character.unicode();
return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z');
};
if (key.size() == 2 && isAsciiLetter(key.at(0)) && isAsciiLetter(key.at(1)))
{
return key.toUpper();
}
for (const auto &alias : countryAliases())
{
if (key == compactKey(alias.first))
{
return alias.second;
}
}
return {};
}
const QHash<QString, CityAlias> &cityAliases()
{
static const QHash<QString, CityAlias> aliases = {
{QStringLiteral("纽约"), {QStringLiteral("New York"), QStringLiteral("US")}},
{QStringLiteral("东京"), {QStringLiteral("Tokyo"), QStringLiteral("JP")}},
{QStringLiteral("伦敦"), {QStringLiteral("London"), QStringLiteral("GB")}},
{QStringLiteral("巴黎"), {QStringLiteral("Paris"), QStringLiteral("FR")}},
{QStringLiteral("柏林"), {QStringLiteral("Berlin"), QStringLiteral("DE")}},
{QStringLiteral("罗马"), {QStringLiteral("Rome"), QStringLiteral("IT")}},
{QStringLiteral("马德里"), {QStringLiteral("Madrid"), QStringLiteral("ES")}},
{QStringLiteral("莫斯科"), {QStringLiteral("Moscow"), QStringLiteral("RU")}},
{QStringLiteral("首尔"), {QStringLiteral("Seoul"), QStringLiteral("KR")}},
{QStringLiteral("曼谷"), {QStringLiteral("Bangkok"), QStringLiteral("TH")}},
{QStringLiteral("吉隆坡"), {QStringLiteral("Kuala Lumpur"), QStringLiteral("MY")}},
{QStringLiteral("雅加达"), {QStringLiteral("Jakarta"), QStringLiteral("ID")}},
{QStringLiteral("河内"), {QStringLiteral("Hanoi"), QStringLiteral("VN")}},
{QStringLiteral("马尼拉"), {QStringLiteral("Manila"), QStringLiteral("PH")}},
{QStringLiteral("新德里"), {QStringLiteral("New Delhi"), QStringLiteral("IN")}},
{QStringLiteral("孟买"), {QStringLiteral("Mumbai"), QStringLiteral("IN")}},
{QStringLiteral("悉尼"), {QStringLiteral("Sydney"), QStringLiteral("AU")}},
{QStringLiteral("墨尔本"), {QStringLiteral("Melbourne"), QStringLiteral("AU")}},
{QStringLiteral("奥克兰"), {QStringLiteral("Auckland"), QStringLiteral("NZ")}},
{QStringLiteral("多伦多"), {QStringLiteral("Toronto"), QStringLiteral("CA")}},
{QStringLiteral("温哥华"), {QStringLiteral("Vancouver"), QStringLiteral("CA")}},
{QStringLiteral("墨西哥城"), {QStringLiteral("Mexico City"), QStringLiteral("MX")}},
{QStringLiteral("圣保罗"), {QStringLiteral("Sao Paulo"), QStringLiteral("BR")}},
{QStringLiteral("里约热内卢"), {QStringLiteral("Rio de Janeiro"), QStringLiteral("BR")}},
{QStringLiteral("开罗"), {QStringLiteral("Cairo"), QStringLiteral("EG")}},
{QStringLiteral("伊斯坦布尔"), {QStringLiteral("Istanbul"), QStringLiteral("TR")}},
{QStringLiteral("迪拜"), {QStringLiteral("Dubai"), QStringLiteral("AE")}},
};
return aliases;
}
QString candidateKey(const WeatherLocationCandidate &candidate)
{
return compactKey(candidate.cityName)
+ QLatin1Char('|')
+ compactKey(candidate.adminName)
+ QLatin1Char('|')
+ compactKey(candidate.countryCode)
+ QLatin1Char('|')
+ QString::number(candidate.latitude, 'f', 4)
+ QLatin1Char('|')
+ QString::number(candidate.longitude, 'f', 4);
}
int placeRank(const QString &featureCode)
{
const QString code = featureCode.trimmed().toUpper();
if (code == QStringLiteral("PPLC"))
{
return 5;
}
if (code == QStringLiteral("PPLA"))
{
return 4;
}
if (code == QStringLiteral("PPLA2"))
{
return 3;
}
if (code == QStringLiteral("PPLA3") || code == QStringLiteral("PPLA4"))
{
return 2;
}
if (code == QStringLiteral("PPL"))
{
return 1;
}
return 0;
}
bool isPlausibleAlternative(
const WeatherLocationCandidate &selected,
const WeatherLocationCandidate &candidate)
{
if (selected.population > 0 && candidate.population > 0)
{
const qint64 populationFloor = qMax<qint64>(50000, selected.population / 5);
if (candidate.population >= populationFloor)
{
return true;
}
}
const int selectedRank = placeRank(selected.featureCode);
const int candidateRank = placeRank(candidate.featureCode);
if (candidateRank >= 3 && candidateRank >= selectedRank - 1)
{
return true;
}
return selected.population <= 0
&& candidate.population <= 0
&& qAbs(candidateRank - selectedRank) <= 1;
}
}
WeatherLocationSearch WeatherLocationResolver::resolveSearch(const QString &cityText)
{
WeatherLocationSearch result;
result.originalName = cityText.trimmed();
QString cityName = result.originalName.simplified();
const QStringList qualifiedParts = cityName.split(
QRegularExpression(QStringLiteral("[,,]")),
Qt::SkipEmptyParts);
if (qualifiedParts.size() >= 2)
{
const QString trailingCountryCode = countryCodeForText(qualifiedParts.last());
const QString leadingCountryCode = countryCodeForText(qualifiedParts.first());
if (!trailingCountryCode.isEmpty())
{
result.countryCode = trailingCountryCode;
cityName = qualifiedParts.first().trimmed();
}
else if (!leadingCountryCode.isEmpty())
{
result.countryCode = leadingCountryCode;
cityName = qualifiedParts.last().trimmed();
}
}
if (result.countryCode.isEmpty())
{
const QString compactCityName = compactKey(cityName);
for (const auto &alias : countryAliases())
{
const QString aliasKey = compactKey(alias.first);
if (compactCityName.startsWith(aliasKey) && compactCityName.size() > aliasKey.size() + 1)
{
result.countryCode = alias.second;
cityName = compactCityName.mid(aliasKey.size());
break;
}
if (compactCityName.endsWith(aliasKey) && compactCityName.size() > aliasKey.size() + 1)
{
result.countryCode = alias.second;
cityName = compactCityName.left(compactCityName.size() - aliasKey.size());
break;
}
}
}
const QString aliasKey = cityAliasKey(cityName);
const auto alias = cityAliases().constFind(aliasKey);
if (alias != cityAliases().constEnd())
{
result.queryName = alias->queryName;
if (result.countryCode.isEmpty())
{
result.countryCode = alias->countryCode;
}
}
else
{
result.queryName = cityName.trimmed();
if (result.queryName.size() > 2 && result.queryName.endsWith(QStringLiteral("市")))
{
result.queryName.chop(1);
}
}
result.countryCode = result.countryCode.trimmed().toUpper();
return result;
}
bool WeatherLocationResolver::parseIpLocationResponse(
const QByteArray &payload,
WeatherIpLocationProvider provider,
WeatherLocation *location)
{
if (location == nullptr)
{
return false;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return false;
}
const QJsonObject root = document.object();
if (root.value(QStringLiteral("error")).toBool(false)
|| (root.contains(QStringLiteral("success")) && !root.value(QStringLiteral("success")).toBool(true)))
{
return false;
}
const QJsonValue latitude = root.value(QStringLiteral("latitude"));
const QJsonValue longitude = root.value(QStringLiteral("longitude"));
const QString cityName = root.value(QStringLiteral("city")).toString().trimmed();
if (!latitude.isDouble() || !longitude.isDouble() || cityName.isEmpty())
{
return false;
}
WeatherLocation parsed;
parsed.cityName = cityName;
parsed.adminName = root.value(QStringLiteral("region")).toString().trimmed();
parsed.countryName = provider == WeatherIpLocationProvider::IpApiCo
? root.value(QStringLiteral("country_name")).toString().trimmed()
: root.value(QStringLiteral("country")).toString().trimmed();
const QJsonValue timezone = root.value(QStringLiteral("timezone"));
parsed.timezone = timezone.isObject()
? timezone.toObject().value(QStringLiteral("id")).toString().trimmed()
: timezone.toString().trimmed();
parsed.latitude = latitude.toDouble();
parsed.longitude = longitude.toDouble();
parsed.source = WeatherLocationSource::IpFallback;
*location = parsed;
return true;
}
QVector<WeatherLocationCandidate> WeatherLocationResolver::selectRelevantCandidates(
const QVector<WeatherLocationCandidate> &candidates,
const WeatherLocationSearch &search)
{
QVector<WeatherLocationCandidate> uniqueCandidates;
QSet<QString> seen;
for (const WeatherLocationCandidate &candidate : candidates)
{
if (!search.countryCode.isEmpty()
&& !candidate.countryCode.isEmpty()
&& candidate.countryCode.compare(search.countryCode, Qt::CaseInsensitive) != 0)
{
continue;
}
const QString key = candidateKey(candidate);
if (seen.contains(key))
{
continue;
}
seen.insert(key);
uniqueCandidates.append(candidate);
}
if (uniqueCandidates.size() <= 1)
{
return uniqueCandidates;
}
QVector<WeatherLocationCandidate> relevantCandidates;
relevantCandidates.append(uniqueCandidates.first());
for (int index = 1; index < uniqueCandidates.size(); ++index)
{
if (isPlausibleAlternative(uniqueCandidates.first(), uniqueCandidates.at(index)))
{
relevantCandidates.append(uniqueCandidates.at(index));
}
}
return relevantCandidates;
}
bool WeatherLocationResolver::hasAmbiguity(const QVector<WeatherLocationCandidate> &candidates)
{
if (candidates.size() <= 1)
{
return false;
}
const QString firstKey = candidateKey(candidates.first());
for (int index = 1; index < candidates.size(); ++index)
{
if (candidateKey(candidates.at(index)) != firstKey)
{
return true;
}
}
return false;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "WeatherTypes.h"
#include <QByteArray>
class WeatherLocationResolver
{
public:
static WeatherLocationSearch resolveSearch(const QString &cityText);
static bool parseIpLocationResponse(
const QByteArray &payload,
WeatherIpLocationProvider provider,
WeatherLocation *location);
static QVector<WeatherLocationCandidate> selectRelevantCandidates(
const QVector<WeatherLocationCandidate> &candidates,
const WeatherLocationSearch &search);
static bool hasAmbiguity(const QVector<WeatherLocationCandidate> &candidates);
};
+652
View File
@@ -0,0 +1,652 @@
#include "WeatherManager.h"
#include "../util/Logger.h"
#include "WeatherLocationResolver.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include <QUrlQuery>
#include <QtGlobal>
#include <utility>
namespace
{
constexpr int ForecastDays = 4;
constexpr int GeocodingCandidateCount = 5;
constexpr int IpLocationProviderCount = 2;
constexpr int IpLocationAttemptTimeoutMs = 5000;
QString normalizedProvider(const WeatherConfig &config)
{
const QString provider = config.provider.trimmed().toLower();
return provider.isEmpty() ? QStringLiteral("open-meteo") : provider;
}
QString normalizedLanguage(const WeatherConfig &config)
{
const QString language = config.language.trimmed().toLower();
return language.isEmpty() ? QStringLiteral("zh") : language;
}
QDateTime dateTimeFromOpenMeteo(const QString &text)
{
QDateTime value = QDateTime::fromString(text, Qt::ISODate);
if (value.isValid())
{
return value;
}
value = QDateTime::fromString(text, QStringLiteral("yyyy-MM-ddTHH:mm"));
if (value.isValid())
{
return value;
}
return {};
}
bool jsonDouble(const QJsonObject &object, const QString &key, double *value)
{
const QJsonValue jsonValue = object.value(key);
if (!jsonValue.isDouble())
{
return false;
}
if (value != nullptr)
{
*value = jsonValue.toDouble();
}
return true;
}
bool jsonInt(const QJsonObject &object, const QString &key, int *value)
{
const QJsonValue jsonValue = object.value(key);
if (!jsonValue.isDouble())
{
return false;
}
if (value != nullptr)
{
*value = jsonValue.toInt();
}
return true;
}
QJsonArray arrayValue(const QJsonObject &object, const QString &key)
{
const QJsonValue value = object.value(key);
return value.isArray() ? value.toArray() : QJsonArray();
}
WeatherLocation locationFromCandidate(const WeatherLocationCandidate &candidate, WeatherLocationSource source)
{
WeatherLocation location;
location.cityName = candidate.cityName;
location.adminName = candidate.adminName;
location.countryName = candidate.countryName;
location.timezone = candidate.timezone;
location.latitude = candidate.latitude;
location.longitude = candidate.longitude;
location.source = source;
return location;
}
QVector<WeatherLocationCandidate> candidatesFromGeocodingResults(const QJsonArray &results)
{
QVector<WeatherLocationCandidate> candidates;
for (const QJsonValue &value : results)
{
if (!value.isObject())
{
continue;
}
const QJsonObject city = value.toObject();
WeatherLocationCandidate candidate;
if (!jsonDouble(city, QStringLiteral("latitude"), &candidate.latitude)
|| !jsonDouble(city, QStringLiteral("longitude"), &candidate.longitude))
{
continue;
}
candidate.cityName = city.value(QStringLiteral("name")).toString();
candidate.adminName = city.value(QStringLiteral("admin1")).toString();
candidate.countryName = city.value(QStringLiteral("country")).toString();
candidate.timezone = city.value(QStringLiteral("timezone")).toString();
candidate.countryCode = city.value(QStringLiteral("country_code")).toString().trimmed().toUpper();
candidate.featureCode = city.value(QStringLiteral("feature_code")).toString().trimmed().toUpper();
const QJsonValue population = city.value(QStringLiteral("population"));
if (population.isDouble())
{
candidate.population = qRound64(population.toDouble());
}
candidates.append(candidate);
}
return candidates;
}
}
WeatherManager::WeatherManager()
{
m_timeoutTimer.setSingleShot(true);
QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() {
if (m_ipLocationAttemptActive)
{
Logger::warning(QStringLiteral("Weather IP location provider timed out: index=%1")
.arg(m_ipLocationProviderIndex));
m_ipLocationAttemptActive = false;
clearReply();
startNextIpLocationRequest();
return;
}
if (m_locationTestOnly)
{
finishLocationTestWithError(QStringLiteral("城市测试超时,请稍后再试。"));
return;
}
finishWithError(QStringLiteral("天气查询超时,请稍后再试。"));
});
}
WeatherManager::~WeatherManager()
{
cancel();
}
bool WeatherManager::isBusy() const
{
return m_busy;
}
void WeatherManager::queryWeather(const QString &text, const WeatherConfig &config, QueryCallback callback)
{
if (isBusy())
{
if (callback)
{
callback({false, {}, QStringLiteral("天气查询正在进行,请稍后。"), {}});
}
return;
}
m_query = m_parser.parse(text);
if (!m_query.unsupportedReason.isEmpty())
{
if (callback)
{
callback({false, {}, m_query.unsupportedReason, {}});
}
return;
}
m_config = config;
m_config.provider = normalizedProvider(m_config);
m_config.language = normalizedLanguage(m_config);
m_config.timeoutMs = qBound(3000, m_config.timeoutMs, 60000);
m_callback = std::move(callback);
m_locationTestCallback = nullptr;
m_locationTestOnly = false;
m_locationCandidates.clear();
m_hasLocationAmbiguity = false;
m_busy = true;
if (m_config.provider != QStringLiteral("open-meteo"))
{
finishWithError(QStringLiteral("当前天气源暂不支持:%1。").arg(m_config.provider));
return;
}
if (!m_query.cityName.trimmed().isEmpty())
{
m_query.locationSource = WeatherLocationSource::ExplicitCity;
startGeocodingRequest(m_query.cityName.trimmed(), WeatherLocationSource::ExplicitCity, false);
return;
}
const QString defaultCity = m_config.defaultCityName.trimmed();
if (!defaultCity.isEmpty())
{
m_query.locationSource = WeatherLocationSource::SettingsDefault;
startGeocodingRequest(defaultCity, WeatherLocationSource::SettingsDefault, false);
return;
}
if (m_config.autoLocateWhenNoDefault)
{
m_query.locationSource = WeatherLocationSource::IpFallback;
startIpLocationRequest();
return;
}
finishWithError(QStringLiteral("没有识别到城市。请在问题里写城市,或到设置页配置默认城市。"));
}
void WeatherManager::resolveLocationForTest(const QString &cityName, const WeatherConfig &config, LocationTestCallback callback)
{
if (isBusy())
{
if (callback)
{
callback({false, QStringLiteral("城市测试正在进行,请稍后。"), {}, {}, false});
}
return;
}
const QString normalizedCityName = cityName.trimmed();
if (normalizedCityName.isEmpty())
{
if (callback)
{
callback({false, QStringLiteral("请先填写默认城市。"), {}, {}, false});
}
return;
}
m_config = config;
m_config.provider = normalizedProvider(m_config);
m_config.language = normalizedLanguage(m_config);
m_config.timeoutMs = qBound(3000, m_config.timeoutMs, 60000);
m_query = {};
m_query.cityName = normalizedCityName;
m_query.locationSource = WeatherLocationSource::SettingsDefault;
m_callback = nullptr;
m_locationTestCallback = std::move(callback);
m_locationTestOnly = true;
m_locationCandidates.clear();
m_hasLocationAmbiguity = false;
m_busy = true;
if (m_config.provider != QStringLiteral("open-meteo"))
{
finishLocationTestWithError(QStringLiteral("当前天气源暂不支持:%1。").arg(m_config.provider));
return;
}
startGeocodingRequest(normalizedCityName, WeatherLocationSource::SettingsDefault, true);
}
void WeatherManager::cancel()
{
m_callback = nullptr;
m_locationTestCallback = nullptr;
m_locationTestOnly = false;
m_locationCandidates.clear();
m_hasLocationAmbiguity = false;
m_locationSearch = {};
m_ipLocationAttemptActive = false;
m_ipLocationProviderIndex = -1;
clearReply();
m_busy = false;
}
void WeatherManager::startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly)
{
m_location = {};
m_location.source = source;
m_locationTestOnly = locationTestOnly;
m_locationSearch = WeatherLocationResolver::resolveSearch(cityName);
if (m_locationSearch.queryName.trimmed().isEmpty())
{
finishGeocodingWithError(QStringLiteral("没有识别到有效城市名称。"));
return;
}
QUrl url(QStringLiteral("https://geocoding-api.open-meteo.com/v1/search"));
QUrlQuery query;
query.addQueryItem(QStringLiteral("name"), m_locationSearch.queryName);
query.addQueryItem(QStringLiteral("count"), QString::number(GeocodingCandidateCount));
query.addQueryItem(QStringLiteral("language"), m_config.language);
query.addQueryItem(QStringLiteral("format"), QStringLiteral("json"));
if (!m_locationSearch.countryCode.isEmpty())
{
query.addQueryItem(QStringLiteral("countryCode"), m_locationSearch.countryCode);
}
url.setQuery(query);
startRequest(url, [this](QNetworkReply *reply) {
finishGeocodingRequest(reply);
});
}
void WeatherManager::startIpLocationRequest()
{
m_location = {};
m_location.source = WeatherLocationSource::IpFallback;
m_locationCandidates.clear();
m_hasLocationAmbiguity = false;
m_ipLocationProviderIndex = -1;
m_ipLocationAttemptActive = false;
startNextIpLocationRequest();
}
void WeatherManager::startNextIpLocationRequest()
{
++m_ipLocationProviderIndex;
if (m_ipLocationProviderIndex >= IpLocationProviderCount)
{
m_ipLocationAttemptActive = false;
finishWithError(QStringLiteral("公网 IP 定位服务暂不可用,请在设置页填写默认城市后重试。"));
return;
}
const WeatherIpLocationProvider provider = m_ipLocationProviderIndex == 0
? WeatherIpLocationProvider::IpApiCo
: WeatherIpLocationProvider::IpWhoIs;
const QUrl url(provider == WeatherIpLocationProvider::IpApiCo
? QStringLiteral("https://ipapi.co/json/")
: QStringLiteral("https://ipwho.is/"));
m_ipLocationAttemptActive = true;
startRequest(url, [this, provider](QNetworkReply *reply) {
finishIpLocationRequest(reply, provider);
}, qMin(m_config.timeoutMs, IpLocationAttemptTimeoutMs));
}
void WeatherManager::startForecastRequest()
{
QUrl url(QStringLiteral("https://api.open-meteo.com/v1/forecast"));
QUrlQuery query;
query.addQueryItem(QStringLiteral("latitude"), QString::number(m_location.latitude, 'f', 6));
query.addQueryItem(QStringLiteral("longitude"), QString::number(m_location.longitude, 'f', 6));
query.addQueryItem(QStringLiteral("current"), QStringLiteral("temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,precipitation"));
query.addQueryItem(QStringLiteral("daily"), QStringLiteral("weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max"));
query.addQueryItem(QStringLiteral("timezone"), QStringLiteral("auto"));
query.addQueryItem(QStringLiteral("forecast_days"), QString::number(ForecastDays));
url.setQuery(query);
startRequest(url, [this](QNetworkReply *reply) {
finishForecastRequest(reply);
});
}
void WeatherManager::finishGeocodingRequest(QNetworkReply *reply)
{
const QByteArray body = reply->readAll();
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError)
{
Logger::warning(QStringLiteral("Weather geocoding request failed: networkError=%1 http=%2")
.arg(static_cast<int>(reply->error()))
.arg(httpStatus));
finishGeocodingWithError(QStringLiteral("城市解析服务暂不可用,请稍后再试。"));
return;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
finishGeocodingWithError(QStringLiteral("城市解析结果格式无效。"));
return;
}
const QJsonArray results = document.object().value(QStringLiteral("results")).toArray();
m_locationCandidates = WeatherLocationResolver::selectRelevantCandidates(
candidatesFromGeocodingResults(results),
m_locationSearch);
if (m_locationCandidates.isEmpty())
{
finishGeocodingWithError(QStringLiteral("没有找到匹配的城市。"));
return;
}
m_hasLocationAmbiguity = WeatherLocationResolver::hasAmbiguity(m_locationCandidates);
m_location = locationFromCandidate(m_locationCandidates.first(), m_location.source);
if (m_locationTestOnly)
{
LocationTestResult result;
result.success = true;
result.selectedLocation = m_location;
result.candidates = m_locationCandidates;
result.hasAmbiguity = m_hasLocationAmbiguity;
invokeLocationTestCallback(result);
return;
}
Logger::info(QStringLiteral("Weather geocoding succeeded: source=%1 http=%2")
.arg(QString::number(static_cast<int>(m_location.source)), QString::number(httpStatus)));
startForecastRequest();
}
void WeatherManager::finishIpLocationRequest(QNetworkReply *reply, WeatherIpLocationProvider provider)
{
m_ipLocationAttemptActive = false;
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError)
{
Logger::warning(QStringLiteral("Weather IP location request failed: provider=%1 networkError=%2 http=%3")
.arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois"))
.arg(static_cast<int>(reply->error()))
.arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
startNextIpLocationRequest();
return;
}
WeatherLocation location;
if (!WeatherLocationResolver::parseIpLocationResponse(body, provider, &location))
{
Logger::warning(QStringLiteral("Weather IP location response was unusable: provider=%1")
.arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois")));
startNextIpLocationRequest();
return;
}
m_location = location;
m_ipLocationProviderIndex = -1;
Logger::info(QStringLiteral("Weather IP location succeeded: provider=%1")
.arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois")));
startForecastRequest();
}
void WeatherManager::finishForecastRequest(QNetworkReply *reply)
{
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError)
{
Logger::warning(QStringLiteral("Weather forecast request failed: networkError=%1 http=%2")
.arg(static_cast<int>(reply->error()))
.arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
finishWithError(QStringLiteral("天气数据服务暂不可用,请稍后再试。"));
return;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
finishWithError(QStringLiteral("天气数据格式无效。"));
return;
}
const QJsonObject root = document.object();
WeatherReport report;
report.query = m_query;
report.location = m_location;
report.dataSourceName = QStringLiteral("Open-Meteo");
report.locationCandidates = m_locationCandidates;
report.hasLocationAmbiguity = m_hasLocationAmbiguity;
const QJsonObject currentObject = root.value(QStringLiteral("current")).toObject();
if (!currentObject.isEmpty())
{
report.current.valid = true;
report.current.time = dateTimeFromOpenMeteo(currentObject.value(QStringLiteral("time")).toString());
report.current.hasTemperature = jsonDouble(currentObject, QStringLiteral("temperature_2m"), &report.current.temperatureC);
report.current.hasApparentTemperature = jsonDouble(currentObject, QStringLiteral("apparent_temperature"), &report.current.apparentTemperatureC);
report.current.hasHumidity = jsonDouble(currentObject, QStringLiteral("relative_humidity_2m"), &report.current.humidityPercent);
report.current.hasWindSpeed = jsonDouble(currentObject, QStringLiteral("wind_speed_10m"), &report.current.windSpeedKmh);
report.current.hasWindDirection = jsonDouble(currentObject, QStringLiteral("wind_direction_10m"), &report.current.windDirectionDegree);
report.current.hasPrecipitation = jsonDouble(currentObject, QStringLiteral("precipitation"), &report.current.precipitationMm);
jsonInt(currentObject, QStringLiteral("weather_code"), &report.current.weatherCode);
}
const QJsonObject dailyObject = root.value(QStringLiteral("daily")).toObject();
const QJsonArray dates = arrayValue(dailyObject, QStringLiteral("time"));
const QJsonArray codes = arrayValue(dailyObject, QStringLiteral("weather_code"));
const QJsonArray maxTemperatures = arrayValue(dailyObject, QStringLiteral("temperature_2m_max"));
const QJsonArray minTemperatures = arrayValue(dailyObject, QStringLiteral("temperature_2m_min"));
const QJsonArray precipitationProbabilities = arrayValue(dailyObject, QStringLiteral("precipitation_probability_max"));
for (int index = 0; index < dates.size(); ++index)
{
WeatherDailyForecast forecast;
forecast.date = QDate::fromString(dates.at(index).toString(), Qt::ISODate);
if (!forecast.date.isValid())
{
continue;
}
if (index < codes.size() && codes.at(index).isDouble())
{
forecast.weatherCode = codes.at(index).toInt();
}
if (index < maxTemperatures.size() && maxTemperatures.at(index).isDouble())
{
forecast.temperatureMaxC = maxTemperatures.at(index).toDouble();
forecast.hasTemperatureMax = true;
}
if (index < minTemperatures.size() && minTemperatures.at(index).isDouble())
{
forecast.temperatureMinC = minTemperatures.at(index).toDouble();
forecast.hasTemperatureMin = true;
}
if (index < precipitationProbabilities.size() && precipitationProbabilities.at(index).isDouble())
{
forecast.precipitationProbabilityPercent = precipitationProbabilities.at(index).toDouble();
forecast.hasPrecipitationProbability = true;
}
report.dailyForecasts.append(forecast);
}
if (!report.current.valid && report.dailyForecasts.isEmpty())
{
finishWithError(QStringLiteral("天气数据缺少当前天气和未来预报。"));
return;
}
finishWithReport(report);
}
void WeatherManager::startRequest(
const QUrl &url,
std::function<void(QNetworkReply *)> finishedHandler,
int timeoutMs)
{
clearReply();
QNetworkRequest request(url);
request.setRawHeader("User-Agent", "QtDesktopPet/0.1");
Logger::info(QStringLiteral("Weather request started: host=%1 path=%2").arg(url.host(), url.path()));
m_currentReply = m_networkManager.get(request);
m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this, finishedHandler = std::move(finishedHandler)]() {
if (m_currentReply.isNull())
{
return;
}
QNetworkReply *reply = m_currentReply;
m_currentReply = nullptr;
m_timeoutTimer.stop();
QObject::disconnect(m_replyFinishedConnection);
m_replyFinishedConnection = {};
finishedHandler(reply);
if (reply != nullptr)
{
reply->deleteLater();
}
});
m_timeoutTimer.start(timeoutMs > 0 ? timeoutMs : m_config.timeoutMs);
}
void WeatherManager::finishWithError(const QString &message)
{
m_ipLocationAttemptActive = false;
m_ipLocationProviderIndex = -1;
clearReply();
WeatherQueryResult result;
result.success = false;
result.errorMessage = message.trimmed().isEmpty() ? QStringLiteral("天气查询失败。") : message.trimmed();
invokeCallback(result);
}
void WeatherManager::finishGeocodingWithError(const QString &message)
{
if (m_locationTestOnly)
{
finishLocationTestWithError(message);
return;
}
finishWithError(message);
}
void WeatherManager::finishLocationTestWithError(const QString &message)
{
clearReply();
LocationTestResult result;
result.success = false;
result.errorMessage = message.trimmed().isEmpty() ? QStringLiteral("城市测试失败。") : message.trimmed();
invokeLocationTestCallback(result);
}
void WeatherManager::finishWithReport(const WeatherReport &report)
{
clearReply();
WeatherQueryResult result;
result.success = true;
result.report = report;
result.message = m_formatter.format(report);
invokeCallback(result);
}
void WeatherManager::clearReply()
{
m_timeoutTimer.stop();
if (m_replyFinishedConnection)
{
QObject::disconnect(m_replyFinishedConnection);
m_replyFinishedConnection = {};
}
QPointer<QNetworkReply> reply = m_currentReply;
m_currentReply = nullptr;
if (!reply.isNull())
{
reply->abort();
reply->deleteLater();
}
}
void WeatherManager::invokeCallback(const WeatherQueryResult &result)
{
m_busy = false;
QueryCallback callback = std::move(m_callback);
m_callback = nullptr;
if (callback)
{
callback(result);
}
}
void WeatherManager::invokeLocationTestCallback(const LocationTestResult &result)
{
m_busy = false;
m_locationTestOnly = false;
LocationTestCallback callback = std::move(m_locationTestCallback);
m_locationTestCallback = nullptr;
if (callback)
{
callback(result);
}
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include "WeatherParser.h"
#include "WeatherLocationResolver.h"
#include "WeatherSummaryFormatter.h"
#include "WeatherTypes.h"
#include <QByteArray>
#include <QMetaObject>
#include <QNetworkAccessManager>
#include <QPointer>
#include <QTimer>
#include <functional>
class QNetworkReply;
class QUrl;
class WeatherManager
{
public:
using QueryCallback = std::function<void(const WeatherQueryResult &)>;
using LocationTestCallback = std::function<void(const LocationTestResult &)>;
WeatherManager();
~WeatherManager();
bool isBusy() const;
void queryWeather(const QString &text, const WeatherConfig &config, QueryCallback callback);
void resolveLocationForTest(const QString &cityName, const WeatherConfig &config, LocationTestCallback callback);
void cancel();
private:
void startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly);
void startIpLocationRequest();
void startNextIpLocationRequest();
void startForecastRequest();
void finishGeocodingRequest(QNetworkReply *reply);
void finishIpLocationRequest(QNetworkReply *reply, WeatherIpLocationProvider provider);
void finishForecastRequest(QNetworkReply *reply);
void startRequest(
const QUrl &url,
std::function<void(QNetworkReply *)> finishedHandler,
int timeoutMs = -1);
void finishGeocodingWithError(const QString &message);
void finishWithError(const QString &message);
void finishLocationTestWithError(const QString &message);
void finishWithReport(const WeatherReport &report);
void clearReply();
void invokeCallback(const WeatherQueryResult &result);
void invokeLocationTestCallback(const LocationTestResult &result);
WeatherParser m_parser;
WeatherSummaryFormatter m_formatter;
QNetworkAccessManager m_networkManager;
QPointer<QNetworkReply> m_currentReply;
QMetaObject::Connection m_replyFinishedConnection;
QTimer m_timeoutTimer;
QueryCallback m_callback;
LocationTestCallback m_locationTestCallback;
WeatherConfig m_config;
WeatherQuery m_query;
WeatherLocationSearch m_locationSearch;
WeatherLocation m_location;
QVector<WeatherLocationCandidate> m_locationCandidates;
bool m_locationTestOnly = false;
bool m_hasLocationAmbiguity = false;
bool m_ipLocationAttemptActive = false;
int m_ipLocationProviderIndex = -1;
bool m_busy = false;
};
+176
View File
@@ -0,0 +1,176 @@
#include "WeatherParser.h"
#include <QRegularExpression>
#include <QStringList>
#include <QtGlobal>
namespace
{
QString simplifiedDigits(QString text)
{
const QList<QPair<QString, QString>> replacements = {
{QStringLiteral("一"), QStringLiteral("1")},
{QStringLiteral("二"), QStringLiteral("2")},
{QStringLiteral("两"), QStringLiteral("2")},
{QStringLiteral("三"), QStringLiteral("3")},
};
for (const auto &replacement : replacements)
{
text.replace(replacement.first, replacement.second);
}
return text;
}
void removeAll(QString *text, const QStringList &patterns)
{
for (const QString &pattern : patterns)
{
text->remove(pattern, Qt::CaseInsensitive);
}
}
QString extractedCityName(QString text)
{
text = simplifiedDigits(text.trimmed());
text.remove(QRegularExpression(QStringLiteral("[,。!?、,.!?;;::]+")));
text.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(" "));
removeAll(&text, {
QStringLiteral("请问"),
QStringLiteral("帮我"),
QStringLiteral("帮忙"),
QStringLiteral("查询"),
QStringLiteral("查一下"),
QStringLiteral("查查"),
QStringLiteral("看看"),
QStringLiteral("看一下"),
QStringLiteral("搜索一下"),
QStringLiteral("搜一下"),
QStringLiteral("一下"),
QStringLiteral("今天"),
QStringLiteral("现在"),
QStringLiteral("当前"),
QStringLiteral("实时"),
QStringLiteral("明天"),
QStringLiteral("后天"),
QStringLiteral("未来1天"),
QStringLiteral("未来2天"),
QStringLiteral("未来3天"),
QStringLiteral("未来一天"),
QStringLiteral("未来二天"),
QStringLiteral("未来两天"),
QStringLiteral("未来三天"),
QStringLiteral("天气怎么样"),
QStringLiteral("天气如何"),
QStringLiteral("天气"),
QStringLiteral("气温"),
QStringLiteral("温度"),
QStringLiteral("冷不冷"),
QStringLiteral("热不热"),
QStringLiteral("会不会下雨"),
QStringLiteral("会下雨吗"),
QStringLiteral("下雨吗"),
QStringLiteral("下雨"),
QStringLiteral("降雨"),
QStringLiteral("降水"),
QStringLiteral("带伞"),
QStringLiteral("风力"),
QStringLiteral("风速"),
QStringLiteral("湿度"),
QStringLiteral("空气质量"),
QStringLiteral("AQI"),
QStringLiteral("aqi"),
QStringLiteral("预警"),
QStringLiteral("穿衣指数"),
QStringLiteral("穿什么"),
QStringLiteral("适合出门吗"),
QStringLiteral("适合出门"),
QStringLiteral("外面怎么样"),
QStringLiteral("怎么样"),
QStringLiteral("如何"),
QStringLiteral("吗"),
QStringLiteral("呢"),
QStringLiteral("的"),
});
text = text.trimmed();
if (!text.contains(QRegularExpression(QStringLiteral("[A-Za-z]"))))
{
text.remove(QChar(' '));
}
return text;
}
int requestedRangeDays(const QString &text)
{
QString normalized = simplifiedDigits(text);
const QRegularExpression rangeExpression(QStringLiteral("未来\\s*([123])\\s*天"));
const QRegularExpressionMatch match = rangeExpression.match(normalized);
if (match.hasMatch())
{
return match.captured(1).toInt();
}
return 0;
}
}
WeatherQuery WeatherParser::parse(const QString &text) const
{
WeatherQuery query;
query.originalText = text.trimmed();
if (query.originalText.isEmpty())
{
query.unsupportedReason = QStringLiteral("天气查询内容为空。");
return query;
}
if (query.originalText.contains(QStringLiteral("空气质量"), Qt::CaseInsensitive)
|| query.originalText.contains(QStringLiteral("AQI"), Qt::CaseInsensitive))
{
query.unsupportedReason = QStringLiteral("天气 v1 暂不支持空气质量查询,目前只能查询基础天气。");
return query;
}
if (query.originalText.contains(QStringLiteral("预警"), Qt::CaseInsensitive))
{
query.unsupportedReason = QStringLiteral("天气 v1 暂不支持天气预警查询,目前只能查询基础天气。");
return query;
}
if (query.originalText.contains(QStringLiteral("穿衣指数"), Qt::CaseInsensitive)
|| query.originalText.contains(QStringLiteral("穿什么"), Qt::CaseInsensitive))
{
query.unsupportedReason = QStringLiteral("天气 v1 暂不支持穿衣指数查询,目前只能查询基础天气。");
return query;
}
const int rangeDays = requestedRangeDays(query.originalText);
if (rangeDays > 0)
{
query.kind = WeatherQueryKind::Range;
query.forecastDays = rangeDays;
query.dateOffset = 0;
}
else if (query.originalText.contains(QStringLiteral("后天"), Qt::CaseInsensitive))
{
query.kind = WeatherQueryKind::Daily;
query.dateOffset = 2;
query.forecastDays = 1;
}
else if (query.originalText.contains(QStringLiteral("明天"), Qt::CaseInsensitive))
{
query.kind = WeatherQueryKind::Daily;
query.dateOffset = 1;
query.forecastDays = 1;
}
else if (query.originalText.contains(QStringLiteral("今天"), Qt::CaseInsensitive))
{
query.kind = WeatherQueryKind::Daily;
query.dateOffset = 0;
query.forecastDays = 1;
}
query.cityName = extractedCityName(query.originalText);
query.forecastDays = qBound(1, query.forecastDays, 3);
return query;
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "WeatherTypes.h"
#include <QString>
class WeatherParser
{
public:
WeatherQuery parse(const QString &text) const;
};
+192
View File
@@ -0,0 +1,192 @@
#include "WeatherStore.h"
#include "../util/Logger.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QSaveFile>
#include <QStandardPaths>
#include <QtGlobal>
namespace
{
constexpr int MinimumWeatherTimeoutMs = 3000;
constexpr int MaximumWeatherTimeoutMs = 60000;
const QString WeatherConfigFileName = QStringLiteral("weather_config.json");
WeatherConfig normalizedConfig(WeatherConfig config)
{
config.provider = config.provider.trimmed().toLower();
if (config.provider.isEmpty())
{
config.provider = QStringLiteral("open-meteo");
}
config.defaultCityName = config.defaultCityName.trimmed();
config.language = config.language.trimmed().toLower();
if (config.language.isEmpty())
{
config.language = QStringLiteral("zh");
}
config.timeoutMs = qBound(MinimumWeatherTimeoutMs, config.timeoutMs, MaximumWeatherTimeoutMs);
return config;
}
QJsonObject objectFromConfig(const WeatherConfig &config)
{
const WeatherConfig normalized = normalizedConfig(config);
QJsonObject root;
root.insert(QStringLiteral("provider"), normalized.provider);
root.insert(QStringLiteral("defaultCityName"), normalized.defaultCityName);
root.insert(QStringLiteral("autoLocateWhenNoDefault"), normalized.autoLocateWhenNoDefault);
root.insert(QStringLiteral("language"), normalized.language);
root.insert(QStringLiteral("timeoutMs"), normalized.timeoutMs);
return root;
}
}
WeatherConfig WeatherStore::load(QString *errorMessage) const
{
WeatherConfig config;
QFile file(storePath());
if (!file.exists())
{
return config;
}
if (!file.open(QIODevice::ReadOnly))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法读取天气配置文件。");
}
Logger::warning(QStringLiteral("Unable to read weather config."));
return config;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
file.close();
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
backupBrokenConfig(storePath());
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("天气配置文件损坏,已备份并使用默认配置。");
}
Logger::warning(QStringLiteral("Weather config is broken; default config will be used."));
return config;
}
const QJsonObject root = document.object();
config.provider = root.value(QStringLiteral("provider")).toString(config.provider);
config.defaultCityName = root.value(QStringLiteral("defaultCityName")).toString(config.defaultCityName);
config.autoLocateWhenNoDefault = root.value(QStringLiteral("autoLocateWhenNoDefault")).toBool(config.autoLocateWhenNoDefault);
config.language = root.value(QStringLiteral("language")).toString(config.language);
config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs);
return normalizedConfig(config);
}
bool WeatherStore::save(const WeatherConfig &config, QString *errorMessage) const
{
QDir directory(configDirectoryPath());
if (!directory.exists() && !directory.mkpath(QStringLiteral(".")))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法创建天气配置目录。");
}
Logger::warning(QStringLiteral("Unable to create weather config directory."));
return false;
}
const QByteArray payload = QJsonDocument(objectFromConfig(config)).toJson(QJsonDocument::Indented);
QSaveFile file(storePath());
if (!file.open(QIODevice::WriteOnly))
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("无法写入天气配置文件。");
}
Logger::warning(QStringLiteral("Unable to open weather config for writing."));
return false;
}
const qint64 written = file.write(payload);
if (written != payload.size())
{
file.cancelWriting();
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("写入天气配置文件不完整。");
}
Logger::warning(QStringLiteral("Weather config write was incomplete."));
return false;
}
if (!file.commit())
{
if (errorMessage != nullptr)
{
*errorMessage = QStringLiteral("提交天气配置文件失败。");
}
Logger::warning(QStringLiteral("Unable to commit weather config."));
return false;
}
return true;
}
QString WeatherStore::storePath() const
{
return QDir(configDirectoryPath()).filePath(WeatherConfigFileName);
}
QString WeatherStore::configDirectoryPath() const
{
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
if (!path.isEmpty())
{
return path;
}
return QDir::currentPath();
}
void WeatherStore::backupBrokenConfig(const QString &filePath) const
{
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(QStringLiteral("weather_config.broken.") + timestamp + QStringLiteral(".json"));
int suffix = 1;
while (QFile::exists(backupPath))
{
backupPath = fileInfo.dir().filePath(
QStringLiteral("weather_config.broken.")
+ timestamp
+ QStringLiteral("-")
+ QString::number(suffix)
+ QStringLiteral(".json"));
++suffix;
}
if (file.rename(backupPath))
{
Logger::warning(QStringLiteral("Broken weather config was backed up: %1").arg(backupPath));
return;
}
Logger::warning(QStringLiteral("Failed to back up broken weather config: %1").arg(filePath));
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "WeatherConfig.h"
#include <QString>
class WeatherStore
{
public:
WeatherConfig load(QString *errorMessage = nullptr) const;
bool save(const WeatherConfig &config, QString *errorMessage = nullptr) const;
QString storePath() const;
private:
QString configDirectoryPath() const;
void backupBrokenConfig(const QString &filePath) const;
};

Some files were not shown because too many files have changed in this diff Show More