Compare commits
7 Commits
v0.1.0
...
90262aaf6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 90262aaf6d | |||
| d0d1de4d00 | |||
| d8950e7648 | |||
| 6812dbb110 | |||
| 6c2926b57a | |||
| c794e32023 | |||
| 4a7b739eea |
@@ -25,4 +25,6 @@ cmake-build-*/
|
||||
/config/
|
||||
/logs/
|
||||
/reports/perf/
|
||||
/reports/qa/
|
||||
/internal/qa/out/
|
||||
*.broken.json
|
||||
|
||||
+43
-3
@@ -7,10 +7,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS 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
|
||||
main.cpp
|
||||
@@ -28,6 +26,11 @@ qt_add_executable(QtDesktopPet
|
||||
src/ai/GoogleGeminiProvider.cpp
|
||||
src/ai/OpenAICompatibleProvider.h
|
||||
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.cpp
|
||||
src/character/CharacterPackage.h
|
||||
@@ -45,8 +48,26 @@ qt_add_executable(QtDesktopPet
|
||||
src/config/ConfigManager.cpp
|
||||
src/config/SecretStore.h
|
||||
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.cpp
|
||||
src/system/StartupManager.h
|
||||
src/system/StartupManager.cpp
|
||||
src/tray/TrayController.h
|
||||
src/tray/TrayController.cpp
|
||||
src/ui/ChatBubble.h
|
||||
@@ -65,6 +86,24 @@ qt_add_executable(QtDesktopPet
|
||||
src/util/Logger.cpp
|
||||
src/util/ResourcePaths.h
|
||||
src/util/ResourcePaths.cpp
|
||||
src/weather/WeatherConfig.h
|
||||
src/weather/WeatherManager.h
|
||||
src/weather/WeatherManager.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
|
||||
@@ -74,6 +113,7 @@ target_compile_definitions(QtDesktopPet
|
||||
|
||||
target_link_libraries(QtDesktopPet
|
||||
PRIVATE
|
||||
Qt6::Multimedia
|
||||
Qt6::Network
|
||||
Qt6::Widgets
|
||||
)
|
||||
|
||||
+531
@@ -0,0 +1,531 @@
|
||||
# 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
|
||||
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
|
||||
```
|
||||
|
||||
生成 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)。
|
||||
@@ -1,215 +1,116 @@
|
||||
# QtDesktopPet
|
||||
|
||||
QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目,当前已具备多状态 PNG 帧动画、托盘控制、角色包导入与切换、用户自定义大模型对话、设置面板和 Windows 发布打包能力。项目现阶段重点是完善稳定性、性能回归、角色管理和发布体验。
|
||||
一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、AI 原生联网、本地提醒和天气查询等能力。
|
||||
|
||||
## 当前状态
|
||||
> 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。
|
||||
|
||||
已实现:
|
||||

|
||||
|
||||
- 透明无边框桌宠窗口
|
||||
- 鼠标拖动
|
||||
- 右键菜单退出和状态测试
|
||||
- 置顶切换
|
||||
- `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 双击不弹控制台窗口
|
||||
## Features
|
||||
|
||||
尚未实现:
|
||||
- 透明无边框桌宠窗口,支持拖动、置顶、托盘隐藏和单实例唤醒。
|
||||
- 多状态 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 定位兜底、多候选提示
|
||||
- Windows 发布脚本和 Inno Setup 安装器脚本。
|
||||
|
||||
- 角色导出和更完整的管理界面
|
||||
- 对话历史导出/管理界面
|
||||
- 长期性能压测记录
|
||||
- 发布包实机安装/卸载验证
|
||||
## Platform
|
||||
|
||||
## 技术栈
|
||||
当前主要目标平台是 Windows 10 / Windows 11。
|
||||
|
||||
项目中已有部分跨平台基础代码,但托盘通知、开机自启动和安装器体验目前按 Windows 优先实现。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- C++17
|
||||
- Qt 6 Widgets
|
||||
- Qt 6 Network
|
||||
- Qt 6 Multimedia
|
||||
- CMake
|
||||
- PNG 图片序列帧
|
||||
- JSON 配置文件
|
||||
- Windows 10 / Windows 11 优先
|
||||
- JSON 配置
|
||||
- PNG 序列帧动画
|
||||
- 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+
|
||||
- Ninja
|
||||
- MinGW 11.2.0 或已配置好的 Qt MSVC Kit
|
||||
- MinGW 11.2.0 or a configured Qt MSVC Kit
|
||||
|
||||
MinGW 示例:
|
||||
Example with MinGW:
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build/mingw-debug -G Ninja `
|
||||
-DCMAKE_BUILD_TYPE=Debug `
|
||||
cmake -S . -B build/mingw-release -G Ninja `
|
||||
-DCMAKE_BUILD_TYPE=Release `
|
||||
-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
|
||||
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
|
||||
|
||||
当前应用图标位于:
|
||||
|
||||
```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 目录下的工具路径:
|
||||
After building a Release executable, package it with:
|
||||
|
||||
```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
|
||||
-ExePath build/release/QtDesktopPet.exe
|
||||
```
|
||||
|
||||
生成 Inno Setup 安装器:
|
||||
To generate the Inno Setup installer:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||
@@ -217,113 +118,103 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||
-BuildInstaller
|
||||
```
|
||||
|
||||
安装器最终默认输出到项目根目录:
|
||||
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
|
||||
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 -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||
-ExePath build/release/QtDesktopPet.exe `
|
||||
-BuildInstaller `
|
||||
-InstallerOutputDir D:\ReleaseOutput
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/prepare_github_export.ps1 `
|
||||
-OutputDir D:\DesktopPet-github-export
|
||||
```
|
||||
|
||||
如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。
|
||||
The export excludes internal docs, reports, build outputs, release packages, local config, logs and Git metadata.
|
||||
|
||||
本地生成的安装包也可以集中放到 `release_packages/`:
|
||||
## License
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||
-ExePath build/release/QtDesktopPet.exe `
|
||||
-BuildInstaller `
|
||||
-InstallerOutputDir release_packages
|
||||
```
|
||||
Source code is released under the MIT License. See [LICENSE](LICENSE).
|
||||
|
||||
`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`。
|
||||
|
||||
安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 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)。
|
||||
Character art, icons, sounds and other media assets may have separate copyright requirements. Confirm asset licensing before public redistribution.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
当前自动化覆盖意图分发、提醒解析和类型、天气解析和模板、联网能力检测、聊天历史兼容,以及源码登记、废弃模块残留、默认资源和 GitHub 导出隔离。真实窗口、托盘通知、音效、网络 Provider、安装卸载仍按手测执行。
|
||||
|
||||
|
||||
## 发布与性能
|
||||
|
||||
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| Release 构建 | 用户手动构建 Release | 构建成功,无新增编译错误 | TODO |
|
||||
| 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、resources、README、LICENSE | 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;性能文档覆盖静置、AI、提醒、天气和联网模式 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 |
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1408,7 +1408,7 @@ Windows 下不能只拷贝 exe。
|
||||
1. 用户手动完成 Release 构建
|
||||
2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径
|
||||
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
|
||||
6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss
|
||||
7. 安装器默认最终输出到项目根目录
|
||||
@@ -1696,8 +1696,9 @@ MIT License 开源
|
||||
当前仍需补齐:
|
||||
|
||||
```text
|
||||
1. 角色包导出和更完整管理界面
|
||||
2. 对话历史导出、搜索或更完整管理界面
|
||||
3. 发布前素材授权确认与打包验证
|
||||
4. 长期性能压测记录
|
||||
1. 发布前素材授权确认与打包验证
|
||||
2. 长期性能压测记录
|
||||
3. 本地文件操作 zip 打包能力,如后续确认压缩库方案再补
|
||||
4. 联网模式后续可补更多 AI Provider 原生联网适配、结构化搜索 API 或自建联网后端;网页全文抓取和长期缓存仍需先确认安全边界
|
||||
5. 应用启动跨平台发现、脚本/命令执行和管理员权限当前不支持,后续如确需增加必须重新评估安全边界
|
||||
```
|
||||
@@ -597,8 +597,8 @@ release_packages/
|
||||
当前实现与计划仍存在差异:
|
||||
|
||||
```text
|
||||
1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程
|
||||
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
|
||||
1. SettingsDialog 已支持角色导入、切换、删除用户角色、导出角色和打开用户角色目录;后续更复杂的角色市场/分享仍不在当前范围
|
||||
2. 对话历史已有内存上限、可选本地保存、关键词搜索、Provider/模型筛选和 Markdown/JSON 导出
|
||||
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
|
||||
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
||||
```
|
||||
@@ -636,12 +636,12 @@ release_packages/
|
||||
|
||||
```text
|
||||
1. 完善设置界面:
|
||||
- 角色导出
|
||||
- 打开用户角色目录
|
||||
- 更完整的角色管理状态提示
|
||||
- 后续如果做角色分享,需要补版权提示和包格式校验
|
||||
2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录
|
||||
3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略
|
||||
4. 做发布包安装/卸载实机验证,并确认 release_packages/ 或根目录安装包输出规则
|
||||
5. 按 docs/QtDesktopPet_测试清单与验收标准.md 做全功能手测
|
||||
```
|
||||
|
||||
---
|
||||
@@ -652,6 +652,6 @@ release_packages/
|
||||
|
||||
```text
|
||||
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
||||
2. 角色管理下一步是否需要导出、打开用户角色目录
|
||||
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
|
||||
2. 角色管理后续是否需要角色分享/市场能力
|
||||
3. 对话历史后续是否需要更复杂的会话分组、归档或全文索引
|
||||
```
|
||||
@@ -22,13 +22,13 @@
|
||||
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条:
|
||||
|
||||
```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 采样:
|
||||
|
||||
```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/` 已加入 `.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 |
|
||||
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `tools/perf_sample.ps1 -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
|
||||
| 启动后静置 | 启动程序后不操作 5 分钟 | `perf_sample.ps1 -Scenario idle-5m` | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
|
||||
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `perf_sample.ps1 -Scenario idle-10m -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | 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 | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 |
|
||||
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `tools/perf_sample.ps1 -DurationSeconds 300` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
|
||||
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
||||
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
|
||||
| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
|
||||
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `perf_sample.ps1 -Scenario state-switch` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
|
||||
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `perf_sample.ps1 -Scenario prewarm-unload -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
||||
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `perf_sample.ps1 -Scenario scale-topmost` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
|
||||
| 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 | 使用备份副本测试 |
|
||||
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
|
||||
|
||||
@@ -75,7 +87,7 @@ reports/perf/
|
||||
```text
|
||||
1. 后台线程预热
|
||||
2. 单帧级缓存
|
||||
3. 自动化长期压测记录
|
||||
3. 经 Release 基线校准的自动性能阈值
|
||||
```
|
||||
|
||||
## 发布包说明
|
||||
@@ -85,6 +97,7 @@ reports/perf/
|
||||
```text
|
||||
tools/
|
||||
docs/
|
||||
internal/
|
||||
reports/
|
||||
build/
|
||||
dist/
|
||||
@@ -99,6 +112,7 @@ QtDesktopPet.exe
|
||||
Qt 运行时依赖
|
||||
resources/characters/
|
||||
resources/icons/
|
||||
resources/sounds/
|
||||
LICENSE
|
||||
README.md
|
||||
```
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# QtDesktopPet 预发布测试记录
|
||||
|
||||
## 构建信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 测试日期 | 2026-07-17 |
|
||||
| 版本 / 发布阶段 | 当前开发版 / 预发布回归进行中 |
|
||||
| 测试源码基线 | `d0d1de4-dirty`;包含已确认的产品能力收缩、无用代码清理和内部 QA 基础设施 |
|
||||
| 工作树说明 | 用户手测后只新增测试记录,未再修改产品业务代码 |
|
||||
| 构建类型 | 用户未反馈,待补充 |
|
||||
| 编译器 / Qt | 待补充 |
|
||||
| 操作系统 | Windows,具体版本待补充 |
|
||||
| 显示器 / DPI | 待补充 |
|
||||
| 测试人 | 用户手动测试 |
|
||||
|
||||
## 自动化摘要
|
||||
|
||||
| 检查 | 结果 | 摘要 |
|
||||
| --- | --- | --- |
|
||||
| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;尚未手动构建内部 QA 工程 |
|
||||
| 静态检查 | PASS | 13 项通过,0 项失败 |
|
||||
| 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 |
|
||||
| GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA |
|
||||
|
||||
## 已执行手测
|
||||
|
||||
| 用例 | 结果 | 实际结果 / 备注 |
|
||||
| --- | --- | --- |
|
||||
| 启动 | PASS | 用户确认通过 |
|
||||
| 拖动 | PASS | 用户确认通过 |
|
||||
| 置顶及重启保持 | PASS | 用户确认通过 |
|
||||
| 托盘隐藏 / 显示 | PASS | 用户确认通过 |
|
||||
| 单实例唤醒 | PASS | 用户确认通过 |
|
||||
| 设置页居中 / 置前 | PASS | 用户确认通过 |
|
||||
| 应用设置保存及重启保持 | PASS | 用户确认通过 |
|
||||
|
||||
## 批次状态
|
||||
|
||||
| 批次 | 范围 | 结果 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 |
|
||||
| T02 | 应用设置与开机自启动 | IN_PROGRESS | 应用设置持久化通过;开机自启动尚未测试 |
|
||||
| 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 | |
|
||||
|
||||
## 性能状态
|
||||
|
||||
实际 QtDesktopPet 性能场景尚未执行。QA 脚本自检结果不得作为产品性能结论。
|
||||
|
||||
## 缺陷与复测
|
||||
|
||||
当前已执行项目未发现缺陷。
|
||||
|
||||
## 当前结论
|
||||
|
||||
- 已执行手测:7 项 `PASS`。
|
||||
- 已执行静态检查:13 项 `PASS`。
|
||||
- 未执行项目仍较多,本记录状态为 `IN_PROGRESS`。
|
||||
- 当前不能据此判定可发布。
|
||||
@@ -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`
|
||||
@@ -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` 登记结论;存在阻断问题时不得标记为可发布。
|
||||
@@ -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
|
||||
- 是否允许发布:否 / 是
|
||||
@@ -0,0 +1,8 @@
|
||||
# QtDesktopPet 测试记录索引
|
||||
|
||||
| 日期 | 版本 / 阶段 | Commit | 构建 | 自动化 | 手测 | 性能 | 发布结论 | 记录 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 2026-07-17 | 预发布回归进行中 | `d0d1de4-dirty` | 用户构建,详情待补 | 静态 13/13;C++ NOT_RUN | 7 PASS,其余 NOT_RUN | NOT_RUN | IN_PROGRESS | `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`。
|
||||
@@ -30,10 +30,14 @@ UninstallDisplayIcon={app}\QtDesktopPet.exe
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
|
||||
Name: "startup"; Description: "开机自启动"; GroupDescription: "Additional options:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
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]
|
||||
Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"
|
||||
Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}"
|
||||
@@ -79,4 +83,9 @@ begin
|
||||
DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet'));
|
||||
DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet'));
|
||||
end;
|
||||
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
begin
|
||||
RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', '{#AppName}');
|
||||
end;
|
||||
end;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
|
||||
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
|
||||
${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/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/ai/ConversationStore.h
|
||||
${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp
|
||||
${PET_SOURCE_ROOT}/src/ai/LLMTypes.h
|
||||
${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)
|
||||
|
||||
include(CTest)
|
||||
add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests)
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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.
|
||||
|
||||
## 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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
@@ -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> ®istry()
|
||||
{
|
||||
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)
|
||||
@@ -0,0 +1,321 @@
|
||||
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 "; " }))
|
||||
|
||||
$infrastructureProblems = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) {
|
||||
$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,
|
||||
[ValidateRange(1, 604800)]
|
||||
[int]$DurationSeconds = 300,
|
||||
[string]$Scenario = "unspecified",
|
||||
[string]$OutputPath = "reports/perf"
|
||||
)
|
||||
|
||||
@@ -35,39 +36,37 @@ function Resolve-TargetProcess {
|
||||
}
|
||||
|
||||
function Read-ProcessSnapshot {
|
||||
param(
|
||||
[System.Diagnostics.Process]$Process
|
||||
)
|
||||
param([System.Diagnostics.Process]$Process)
|
||||
|
||||
$path = ""
|
||||
try {
|
||||
$path = $Process.Path
|
||||
}
|
||||
catch {
|
||||
$path = ""
|
||||
}
|
||||
try { $path = $Process.Path } catch { $path = "" }
|
||||
|
||||
$responding = ""
|
||||
try {
|
||||
$responding = $Process.Responding
|
||||
}
|
||||
catch {
|
||||
$responding = ""
|
||||
}
|
||||
try { $responding = $Process.Responding } catch { $responding = "" }
|
||||
|
||||
return [pscustomobject]@{
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $Process.Id
|
||||
CpuSeconds = [double]($Process.CPU)
|
||||
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
|
||||
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
|
||||
HandleCount = $Process.HandleCount
|
||||
ThreadCount = $Process.Threads.Count
|
||||
Responding = $responding
|
||||
Path = $path
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $Process.Id
|
||||
CpuSeconds = [double]($Process.CPU)
|
||||
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
|
||||
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
|
||||
HandleCount = $Process.HandleCount
|
||||
ThreadCount = $Process.Threads.Count
|
||||
Responding = $responding
|
||||
Path = $path
|
||||
}
|
||||
}
|
||||
|
||||
function 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 {
|
||||
param(
|
||||
[string]$Directory,
|
||||
@@ -87,37 +86,35 @@ $target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
|
||||
if ($null -eq $target) {
|
||||
if ($ProcessId -gt 0) {
|
||||
Write-Error "Process with PID $ProcessId was not found."
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
$logicalProcessorCount = [Environment]::ProcessorCount
|
||||
if ($logicalProcessorCount -lt 1) {
|
||||
$logicalProcessorCount = 1
|
||||
}
|
||||
|
||||
$outputFile = New-OutputFilePath -Directory $OutputPath -Name $target.ProcessName
|
||||
$logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount)
|
||||
$outputDirectory = Resolve-OutputDirectory -Path $OutputPath
|
||||
$outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)"
|
||||
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
|
||||
|
||||
Write-Host "Sampling PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
||||
Write-Host "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
||||
Write-Host "Output: $outputFile"
|
||||
|
||||
$previousSnapshot = Read-ProcessSnapshot -Process $target
|
||||
$previousTime = Get-Date
|
||||
$firstRow = [pscustomobject]@{
|
||||
TimestampUtc = $previousSnapshot.TimestampUtc
|
||||
Pid = $previousSnapshot.Pid
|
||||
CpuPercent = 0
|
||||
WorkingSetMB = $previousSnapshot.WorkingSetMB
|
||||
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
|
||||
HandleCount = $previousSnapshot.HandleCount
|
||||
ThreadCount = $previousSnapshot.ThreadCount
|
||||
Responding = $previousSnapshot.Responding
|
||||
Path = $previousSnapshot.Path
|
||||
Status = "running"
|
||||
Scenario = $Scenario
|
||||
SampleIndex = 0
|
||||
TimestampUtc = $previousSnapshot.TimestampUtc
|
||||
Pid = $previousSnapshot.Pid
|
||||
CpuPercent = 0
|
||||
WorkingSetMB = $previousSnapshot.WorkingSetMB
|
||||
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
|
||||
HandleCount = $previousSnapshot.HandleCount
|
||||
ThreadCount = $previousSnapshot.ThreadCount
|
||||
Responding = $previousSnapshot.Responding
|
||||
Path = $previousSnapshot.Path
|
||||
Status = "running"
|
||||
}
|
||||
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
|
||||
|
||||
@@ -127,16 +124,18 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
||||
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
|
||||
if ($null -eq $currentProcess) {
|
||||
[pscustomobject]@{
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $target.Id
|
||||
CpuPercent = ""
|
||||
WorkingSetMB = ""
|
||||
PrivateMemoryMB = ""
|
||||
HandleCount = ""
|
||||
ThreadCount = ""
|
||||
Responding = ""
|
||||
Path = $previousSnapshot.Path
|
||||
Status = "exited"
|
||||
Scenario = $Scenario
|
||||
SampleIndex = $index
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $target.Id
|
||||
CpuPercent = ""
|
||||
WorkingSetMB = ""
|
||||
PrivateMemoryMB = ""
|
||||
HandleCount = ""
|
||||
ThreadCount = ""
|
||||
Responding = ""
|
||||
Path = $previousSnapshot.Path
|
||||
Status = "exited"
|
||||
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
||||
|
||||
Write-Warning "Process $($target.Id) exited. Sampling stopped."
|
||||
@@ -147,25 +146,25 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
||||
$currentTime = Get-Date
|
||||
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
|
||||
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
|
||||
if ($cpuPercent -lt 0) {
|
||||
$cpuPercent = 0
|
||||
}
|
||||
if ($cpuPercent -lt 0) { $cpuPercent = 0 }
|
||||
|
||||
[pscustomobject]@{
|
||||
TimestampUtc = $currentSnapshot.TimestampUtc
|
||||
Pid = $currentSnapshot.Pid
|
||||
CpuPercent = [math]::Round($cpuPercent, 2)
|
||||
WorkingSetMB = $currentSnapshot.WorkingSetMB
|
||||
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
|
||||
HandleCount = $currentSnapshot.HandleCount
|
||||
ThreadCount = $currentSnapshot.ThreadCount
|
||||
Responding = $currentSnapshot.Responding
|
||||
Path = $currentSnapshot.Path
|
||||
Status = "running"
|
||||
Scenario = $Scenario
|
||||
SampleIndex = $index
|
||||
TimestampUtc = $currentSnapshot.TimestampUtc
|
||||
Pid = $currentSnapshot.Pid
|
||||
CpuPercent = [math]::Round($cpuPercent, 2)
|
||||
WorkingSetMB = $currentSnapshot.WorkingSetMB
|
||||
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
|
||||
HandleCount = $currentSnapshot.HandleCount
|
||||
ThreadCount = $currentSnapshot.ThreadCount
|
||||
Responding = $currentSnapshot.Responding
|
||||
Path = $currentSnapshot.Path
|
||||
Status = "running"
|
||||
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
||||
|
||||
$previousSnapshot = $currentSnapshot
|
||||
$previousTime = $currentTime
|
||||
}
|
||||
|
||||
Write-Host "Sampling finished."
|
||||
Write-Host "Sampling finished: $outputFile"
|
||||
@@ -0,0 +1,44 @@
|
||||
#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);
|
||||
int failedCount = 0;
|
||||
|
||||
for (const Qa::TestCase &test : Qa::registry())
|
||||
{
|
||||
try
|
||||
{
|
||||
test.function();
|
||||
output << "[PASS] " << QString::fromStdString(test.name) << 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 " << Qa::registry().size() << " tests; "
|
||||
<< failedCount << " failed." << Qt::endl;
|
||||
return failedCount == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -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,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"));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/weather/WeatherParser.h"
|
||||
#include "src/weather/WeatherSummaryFormatter.h"
|
||||
|
||||
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(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("未配置完整")));
|
||||
}
|
||||
@@ -76,6 +76,9 @@ int main(int argc, char *argv[])
|
||||
|
||||
TrayController trayController(&window);
|
||||
window.setSettingsFallbackInContextMenuEnabled(!trayController.isAvailable());
|
||||
window.setTrayNotificationCallback([&trayController](const QString &title, const QString &message) {
|
||||
return trayController.showNotification(title, message);
|
||||
});
|
||||
trayController.show();
|
||||
|
||||
QObject::connect(&singleInstanceServer, &QLocalServer::newConnection, [&singleInstanceServer, &window]() {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,33 @@
|
||||
#include "ConversationManager.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QtGlobal>
|
||||
#include <utility>
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (isBusy())
|
||||
@@ -77,48 +106,30 @@ bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback)
|
||||
ChatRequest ConversationManager::buildRequestForUserMessage(const QString &message) const
|
||||
{
|
||||
const QString content = message.trimmed();
|
||||
if (content.isEmpty())
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
callback({false, {}, QStringLiteral("Message is empty."), 0});
|
||||
}
|
||||
return;
|
||||
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;
|
||||
}
|
||||
|
||||
if (isBusy())
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
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);
|
||||
}
|
||||
});
|
||||
const ChatMessage user{QStringLiteral("user"), userContent, QDateTime::currentDateTime(), m_currentProvider, m_currentModel};
|
||||
const ChatMessage assistant{QStringLiteral("assistant"), assistantContent, QDateTime::currentDateTime(), m_currentProvider, m_currentModel};
|
||||
appendExchange(user, assistant);
|
||||
}
|
||||
|
||||
void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback)
|
||||
@@ -151,14 +162,16 @@ void ConversationManager::sendUserMessageStreaming(const QString &message, Strea
|
||||
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(
|
||||
buildRequest(userMessage),
|
||||
std::move(streamCallback),
|
||||
[this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
|
||||
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)
|
||||
@@ -192,7 +205,7 @@ ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) co
|
||||
ChatRequest request;
|
||||
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);
|
||||
|
||||
@@ -23,8 +23,10 @@ public:
|
||||
void setHistory(const QVector<ChatMessage> &history);
|
||||
void setRequestContextMessageLimit(int maxMessages);
|
||||
void setMemoryHistoryMessageLimit(int maxMessages);
|
||||
void setConversationMetadata(const QString &provider, const QString &model);
|
||||
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 cancel();
|
||||
void clear();
|
||||
@@ -38,6 +40,8 @@ private:
|
||||
std::unique_ptr<LLMProvider> m_provider;
|
||||
QVector<ChatMessage> m_history;
|
||||
QString m_systemPrompt;
|
||||
QString m_currentProvider;
|
||||
QString m_currentModel;
|
||||
int m_maxRequestContextMessages = 12;
|
||||
int m_maxStoredHistoryMessages = 200;
|
||||
int m_prunedHistoryMessageCount = 0;
|
||||
|
||||
@@ -24,14 +24,39 @@ QJsonObject objectFromMessage(const ChatMessage &message)
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("role"), message.role);
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
@@ -7,6 +8,9 @@ struct ChatMessage
|
||||
{
|
||||
QString role;
|
||||
QString content;
|
||||
QDateTime timestamp;
|
||||
QString provider;
|
||||
QString model;
|
||||
};
|
||||
|
||||
struct ChatRequest
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "UserIntent.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
class IntentRouter
|
||||
{
|
||||
public:
|
||||
UserIntent route(const QString &text) const;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QJsonValue>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -535,18 +536,6 @@ QVector<CharacterPackageInfo> CharacterPackageRepository::availablePackages()
|
||||
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 result;
|
||||
@@ -629,6 +618,70 @@ bool CharacterPackageRepository::importPackageDirectory(
|
||||
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)
|
||||
{
|
||||
const QString trimmed = characterId.trimmed();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
struct CharacterPackageInfo
|
||||
@@ -40,7 +39,6 @@ public:
|
||||
static bool hasBuiltInPackage(const QString &characterId);
|
||||
static bool hasUserPackage(const QString &characterId);
|
||||
static QVector<CharacterPackageInfo> availablePackages();
|
||||
static QStringList availablePackageIds();
|
||||
static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath);
|
||||
static bool validatePackageDirectory(
|
||||
const QString &directoryPath,
|
||||
@@ -52,5 +50,11 @@ public:
|
||||
bool overwrite,
|
||||
QString *importedCharacterId = 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);
|
||||
};
|
||||
|
||||
@@ -79,11 +79,6 @@ bool FrameAnimator::isPlaying() const
|
||||
return m_timer.isActive() && m_clip != nullptr;
|
||||
}
|
||||
|
||||
bool FrameAnimator::isPaused() const
|
||||
{
|
||||
return m_paused && m_clip != nullptr;
|
||||
}
|
||||
|
||||
QString FrameAnimator::currentStateName() const
|
||||
{
|
||||
if (m_clip == nullptr)
|
||||
|
||||
@@ -22,7 +22,6 @@ public:
|
||||
void resume();
|
||||
void stop();
|
||||
bool isPlaying() const;
|
||||
bool isPaused() const;
|
||||
QString currentStateName() const;
|
||||
|
||||
private:
|
||||
|
||||
@@ -8,6 +8,7 @@ struct AppConfig
|
||||
QPoint windowPosition = QPoint(100, 100);
|
||||
bool hasWindowPosition = false;
|
||||
bool alwaysOnTop = true;
|
||||
bool launchAtStartup = false;
|
||||
double scale = 1.0;
|
||||
QString performanceMode = QStringLiteral("standard");
|
||||
bool pauseWhenHidden = true;
|
||||
@@ -16,6 +17,9 @@ struct AppConfig
|
||||
int animationCacheLimitMb = 180;
|
||||
bool unloadAnimationsWhenHidden = true;
|
||||
QString characterId = QStringLiteral("shiroko");
|
||||
QString reminderSoundId = QStringLiteral("reminder_default");
|
||||
bool reminderSoundEnabled = true;
|
||||
double reminderSoundVolume = 0.8;
|
||||
int requestContextMessageLimit = 12;
|
||||
int memoryHistoryMessageLimit = 200;
|
||||
bool saveConversationHistory = false;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QStandardPaths>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -28,6 +29,13 @@ QJsonObject windowObjectFromConfig(const AppConfig &config)
|
||||
return window;
|
||||
}
|
||||
|
||||
QJsonObject systemObjectFromConfig(const AppConfig &config)
|
||||
{
|
||||
QJsonObject system;
|
||||
system.insert(QStringLiteral("launchAtStartup"), config.launchAtStartup);
|
||||
return system;
|
||||
}
|
||||
|
||||
QJsonObject performanceObjectFromConfig(const AppConfig &config)
|
||||
{
|
||||
QJsonObject performance;
|
||||
@@ -57,6 +65,15 @@ QJsonObject characterObjectFromConfig(const AppConfig &config)
|
||||
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)
|
||||
{
|
||||
const QString normalized = provider.trimmed().toLower();
|
||||
@@ -200,6 +217,12 @@ AppConfig ConfigManager::loadAppConfig() const
|
||||
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();
|
||||
if (performance.contains(QStringLiteral("mode")))
|
||||
{
|
||||
@@ -258,6 +281,26 @@ AppConfig ConfigManager::loadAppConfig() const
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -343,9 +386,11 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("system"), systemObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config));
|
||||
|
||||
QFile file(appConfigPath());
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return QDir(configDirectoryPath()).filePath(AppConfigFileName);
|
||||
|
||||
@@ -15,7 +15,6 @@ public:
|
||||
AIConfig loadAIConfig() const;
|
||||
bool saveAppConfig(const AppConfig &config) const;
|
||||
bool saveAIConfigStore(const AIConfigStore &store) const;
|
||||
bool saveAIConfig(const AIConfig &config) const;
|
||||
QString appConfigPath() const;
|
||||
QString aiConfigPath() 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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("没有识别到有效提醒命令。"), {}};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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')));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ¤tDate)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QSoundEffect>
|
||||
#include <QString>
|
||||
|
||||
class ReminderSoundPlayer
|
||||
{
|
||||
public:
|
||||
void play(const QString &soundId, double volume);
|
||||
|
||||
private:
|
||||
QSoundEffect m_soundEffect;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace StartupManager
|
||||
{
|
||||
QString startupEntryName();
|
||||
QString startupCommandForCurrentExecutable();
|
||||
bool isLaunchAtStartupEnabled();
|
||||
bool setLaunchAtStartupEnabled(bool enabled, QString *errorMessage = nullptr);
|
||||
}
|
||||
@@ -62,6 +62,17 @@ void TrayController::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()
|
||||
{
|
||||
QAction *showAction = m_menu.addAction(QStringLiteral("显示桌宠"));
|
||||
|
||||
@@ -12,6 +12,7 @@ public:
|
||||
|
||||
bool isAvailable() const;
|
||||
void show();
|
||||
bool showNotification(const QString &title, const QString &message);
|
||||
|
||||
private:
|
||||
void createMenu();
|
||||
|
||||
+1
-43
@@ -1,11 +1,11 @@
|
||||
#include "ChatBubble.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QEvent>
|
||||
#include <QFrame>
|
||||
#include <QRect>
|
||||
#include <QScrollBar>
|
||||
#include <QSize>
|
||||
#include <QTextEdit>
|
||||
#include <QTextDocument>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
@@ -48,7 +48,6 @@ ChatBubble::ChatBubble(QWidget *parent)
|
||||
m_textEdit->installEventFilter(this);
|
||||
m_textEdit->viewport()->installEventFilter(this);
|
||||
m_textEdit->verticalScrollBar()->installEventFilter(this);
|
||||
qApp->installEventFilter(this);
|
||||
m_textEdit->setStyleSheet(QStringLiteral(
|
||||
"QTextEdit {"
|
||||
"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)
|
||||
{
|
||||
m_dismissOnExternalInteraction = false;
|
||||
m_anchorPosition = anchorPosition;
|
||||
m_autoHideDurationMs = durationMs;
|
||||
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()
|
||||
{
|
||||
if (isVisible() && m_autoHideDurationMs > 0)
|
||||
@@ -150,18 +135,6 @@ void ChatBubble::hideBubble()
|
||||
|
||||
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())
|
||||
&& isUserInteractionEvent(event))
|
||||
{
|
||||
@@ -171,21 +144,6 @@ bool ChatBubble::eventFilter(QObject *watched, QEvent *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
|
||||
{
|
||||
switch (event->type())
|
||||
|
||||
+1
-5
@@ -1,16 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <QTextEdit>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
class QEvent;
|
||||
class QTextEdit;
|
||||
|
||||
class ChatBubble : public QWidget
|
||||
{
|
||||
public:
|
||||
explicit ChatBubble(QWidget *parent = nullptr);
|
||||
~ChatBubble() override;
|
||||
|
||||
void showMessage(
|
||||
const QString &message,
|
||||
@@ -18,7 +17,6 @@ public:
|
||||
int durationMs = 10000,
|
||||
bool scrollToBottom = false);
|
||||
void updateAnchorPosition(const QPoint &anchorPosition);
|
||||
void setDismissOnExternalInteraction(bool enabled);
|
||||
void resetAutoHideTimer();
|
||||
void hideBubble();
|
||||
|
||||
@@ -26,7 +24,6 @@ protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
bool isOwnObject(QObject *object) const;
|
||||
bool isUserInteractionEvent(QEvent *event) const;
|
||||
QSize preferredBubbleSize(const QString &message) const;
|
||||
void updatePosition();
|
||||
@@ -35,5 +32,4 @@ private:
|
||||
QTimer m_hideTimer;
|
||||
QPoint m_anchorPosition;
|
||||
int m_autoHideDurationMs = 0;
|
||||
bool m_dismissOnExternalInteraction = false;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "ChatInputDialog.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QEvent>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
@@ -8,6 +9,7 @@
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPushButton>
|
||||
#include <QSignalBlocker>
|
||||
#include <QTextEdit>
|
||||
|
||||
#include <utility>
|
||||
@@ -16,9 +18,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, m_textEdit(new QTextEdit(this))
|
||||
, m_counterLabel(new QLabel(this))
|
||||
, m_webToggleCheckBox(new QCheckBox(QStringLiteral("联网"), this))
|
||||
, m_sendButton(new QPushButton(QStringLiteral("↗"), this))
|
||||
, m_maxLength(maxLength)
|
||||
{
|
||||
m_webToggleCheckBox->setObjectName(QStringLiteral("WebToggle"));
|
||||
|
||||
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setModal(false);
|
||||
@@ -59,6 +64,27 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
||||
"QPushButton:disabled {"
|
||||
"color: rgba(32, 33, 36, 80);"
|
||||
"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 换行"));
|
||||
@@ -73,12 +99,20 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
||||
m_counterLabel->setStyleSheet(QStringLiteral("color: #b3261e; font-size: 12px;"));
|
||||
m_counterLabel->setVisible(false);
|
||||
|
||||
m_webToggleCheckBox->setToolTip(QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。"));
|
||||
|
||||
connect(m_sendButton, &QPushButton::clicked, this, [this]() {
|
||||
submitIfValid();
|
||||
});
|
||||
connect(m_textEdit, &QTextEdit::textChanged, this, [this]() {
|
||||
updateCounter();
|
||||
});
|
||||
connect(m_webToggleCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
if (m_webToggleChangedCallback)
|
||||
{
|
||||
m_webToggleChangedCallback(checked);
|
||||
}
|
||||
});
|
||||
|
||||
auto *panel = new QFrame(this);
|
||||
panel->setObjectName(QStringLiteral("InputPanel"));
|
||||
@@ -89,6 +123,7 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
||||
panelLayout->setSpacing(8);
|
||||
panelLayout->addWidget(m_textEdit, 1);
|
||||
panelLayout->addWidget(m_counterLabel);
|
||||
panelLayout->addWidget(m_webToggleCheckBox);
|
||||
panelLayout->addWidget(m_sendButton);
|
||||
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
@@ -104,11 +139,50 @@ QString ChatInputDialog::message() const
|
||||
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)
|
||||
{
|
||||
m_submitCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void ChatInputDialog::setWebToggleChangedCallback(WebToggleChangedCallback callback)
|
||||
{
|
||||
m_webToggleChangedCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void ChatInputDialog::showAt(const QPoint &anchorPosition)
|
||||
{
|
||||
move(anchorPosition.x() - width() / 2, anchorPosition.y() - height() / 2);
|
||||
@@ -217,7 +291,7 @@ bool ChatInputDialog::submitIfValid()
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
clearMessage();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <functional>
|
||||
|
||||
class QEvent;
|
||||
class QCheckBox;
|
||||
class QLabel;
|
||||
class QMouseEvent;
|
||||
class QPushButton;
|
||||
@@ -15,12 +16,17 @@ class QTextEdit;
|
||||
class ChatInputDialog : public QDialog
|
||||
{
|
||||
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);
|
||||
|
||||
QString message() const;
|
||||
bool webEnabled() const;
|
||||
void setWebEnabled(bool enabled);
|
||||
void setWebToggleAvailable(bool available, const QString &toolTip = QString());
|
||||
void setSubmitCallback(SubmitCallback callback);
|
||||
void setWebToggleChangedCallback(WebToggleChangedCallback callback);
|
||||
void showAt(const QPoint &anchorPosition);
|
||||
|
||||
protected:
|
||||
@@ -41,8 +47,10 @@ private:
|
||||
|
||||
QTextEdit *m_textEdit = nullptr;
|
||||
QLabel *m_counterLabel = nullptr;
|
||||
QCheckBox *m_webToggleCheckBox = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
SubmitCallback m_submitCallback;
|
||||
WebToggleChangedCallback m_webToggleChangedCallback;
|
||||
QPoint m_dragStartPosition;
|
||||
QPoint m_dragWindowPosition;
|
||||
int m_maxLength = 0;
|
||||
|
||||
+758
-23
@@ -3,10 +3,22 @@
|
||||
#include "../ai/AIProviderFactory.h"
|
||||
#include "../ai/ConversationManager.h"
|
||||
#include "../ai/ConversationStore.h"
|
||||
#include "../assistant/CommandDispatcher.h"
|
||||
#include "../character/CharacterPackageLoader.h"
|
||||
#include "../character/CharacterPackageRepository.h"
|
||||
#include "../config/ConfigManager.h"
|
||||
#include "../notification/NotificationDispatcher.h"
|
||||
#include "../reminder/ReminderCommandHandler.h"
|
||||
#include "../reminder/ReminderManager.h"
|
||||
#include "../reminder/ReminderSoundPlayer.h"
|
||||
#include "../reminder/ReminderSoundRepository.h"
|
||||
#include "../system/StartupManager.h"
|
||||
#include "../util/Logger.h"
|
||||
#include "../web/WebCapabilityDetector.h"
|
||||
#include "../web/WebChatManager.h"
|
||||
#include "../web/WebStore.h"
|
||||
#include "../weather/WeatherManager.h"
|
||||
#include "../weather/WeatherStore.h"
|
||||
#include "ChatBubble.h"
|
||||
#include "ChatHistoryPanel.h"
|
||||
#include "ChatInputDialog.h"
|
||||
@@ -18,14 +30,18 @@
|
||||
#include <QContextMenuEvent>
|
||||
#include <QCursor>
|
||||
#include <QDialog>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QGuiApplication>
|
||||
#include <QHideEvent>
|
||||
#include <QList>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QMouseEvent>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QRandomGenerator>
|
||||
#include <QScreen>
|
||||
#include <QSet>
|
||||
@@ -36,6 +52,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -72,6 +89,7 @@ QString megabytesText(qint64 bytes)
|
||||
|
||||
AppConfig normalizedAppConfig(AppConfig config)
|
||||
{
|
||||
config.launchAtStartup = StartupManager::isLaunchAtStartupEnabled();
|
||||
config.scale = qBound(0.5, config.scale, 2.0);
|
||||
if (config.performanceMode != QStringLiteral("standard")
|
||||
&& config.performanceMode != QStringLiteral("low-power"))
|
||||
@@ -87,6 +105,8 @@ AppConfig normalizedAppConfig(AppConfig config)
|
||||
{
|
||||
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||
}
|
||||
config.reminderSoundId = ReminderSoundRepository::soundInfo(config.reminderSoundId).id;
|
||||
config.reminderSoundVolume = qBound(0.0, config.reminderSoundVolume, 1.0);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -146,6 +166,22 @@ QString userVisibleErrorMessage(const ChatResponse &response)
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
QString webCitationListText(const QVector<WebCitation> &citations)
|
||||
{
|
||||
QStringList lines;
|
||||
const int count = qMin(citations.size(), 10);
|
||||
for (int index = 0; index < count; ++index)
|
||||
{
|
||||
const WebCitation &citation = citations.at(index);
|
||||
lines.append(QStringLiteral("[%1] %2\n%3")
|
||||
.arg(index + 1)
|
||||
.arg(citation.title.trimmed().isEmpty() ? QStringLiteral("来源") : citation.title.trimmed())
|
||||
.arg(citation.url));
|
||||
}
|
||||
return lines.join(QStringLiteral("\n"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PetWindow::PetWindow(QWidget *parent)
|
||||
@@ -155,6 +191,11 @@ PetWindow::PetWindow(QWidget *parent)
|
||||
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
|
||||
, m_conversationManager(std::make_unique<ConversationManager>())
|
||||
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
|
||||
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
|
||||
, m_reminderManager(std::make_unique<ReminderManager>())
|
||||
, m_reminderSoundPlayer(std::make_unique<ReminderSoundPlayer>())
|
||||
, m_webChatManager(std::make_unique<WebChatManager>())
|
||||
, m_weatherManager(std::make_unique<WeatherManager>())
|
||||
, m_petView(new PetView(this))
|
||||
, m_dragging(false)
|
||||
, m_alwaysOnTop(true)
|
||||
@@ -198,8 +239,21 @@ PetWindow::PetWindow(QWidget *parent)
|
||||
});
|
||||
|
||||
QPointer<PetWindow> window(this);
|
||||
m_chatInputDialog->setSubmitCallback([window](const QString &message) {
|
||||
return !window.isNull() && window->submitChatMessage(message);
|
||||
refreshChatInputWebToggle();
|
||||
m_chatInputDialog->setSubmitCallback([window](const QString &message, bool webEnabled) {
|
||||
return !window.isNull() && window->submitChatMessage(message, webEnabled);
|
||||
});
|
||||
m_chatInputDialog->setWebToggleChangedCallback([window](bool webEnabled) {
|
||||
if (!window.isNull())
|
||||
{
|
||||
window->saveWebTogglePreference(webEnabled);
|
||||
}
|
||||
});
|
||||
m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) {
|
||||
if (!window.isNull())
|
||||
{
|
||||
window->handleTriggeredReminder(item);
|
||||
}
|
||||
});
|
||||
|
||||
loadInitialImage();
|
||||
@@ -278,6 +332,7 @@ AppConfig PetWindow::currentAppConfig() const
|
||||
config.windowPosition = pos();
|
||||
config.hasWindowPosition = true;
|
||||
config.alwaysOnTop = m_alwaysOnTop;
|
||||
config.launchAtStartup = StartupManager::isLaunchAtStartupEnabled();
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -314,17 +369,92 @@ void PetWindow::resumeAnimation()
|
||||
|
||||
void PetWindow::showBubbleMessage(const QString &message)
|
||||
{
|
||||
hideReminderActions();
|
||||
m_chatBubble->showMessage(message, bubbleAnchorPosition());
|
||||
}
|
||||
|
||||
void PetWindow::openSettingsDialog()
|
||||
{
|
||||
ConfigManager configManager;
|
||||
SettingsDialog dialog(configManager.loadAIConfigStore(), currentAppConfig(), [this]() {
|
||||
return isManualStateSwitchLocked();
|
||||
}, [this]() {
|
||||
clearConversation();
|
||||
}, this);
|
||||
WeatherStore weatherStore;
|
||||
WebStore webStore;
|
||||
QString weatherConfigError;
|
||||
const WeatherConfig weatherConfig = weatherStore.load(&weatherConfigError);
|
||||
if (!weatherConfigError.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Weather config load warning: ") + weatherConfigError);
|
||||
}
|
||||
QString webConfigError;
|
||||
const WebConfig webConfig = webStore.load(&webConfigError);
|
||||
if (!webConfigError.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Web config load warning: ") + webConfigError);
|
||||
}
|
||||
SettingsDialog dialog(
|
||||
configManager.loadAIConfigStore(),
|
||||
currentAppConfig(),
|
||||
weatherConfig,
|
||||
webConfig,
|
||||
m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>(),
|
||||
m_reminderManager ? m_reminderManager->allReminders() : QVector<ReminderItem>(),
|
||||
[this]() {
|
||||
return isManualStateSwitchLocked();
|
||||
},
|
||||
[this]() {
|
||||
clearConversation();
|
||||
},
|
||||
[this](const QString &reminderId, QString *errorMessage) {
|
||||
return m_reminderManager && m_reminderManager->cancelReminder(reminderId, errorMessage);
|
||||
},
|
||||
[this](const QString &reminderId, const QString &title, const QDateTime &remindAt, const ReminderRecurrence &recurrence, ReminderItem *updatedItem, QString *errorMessage) {
|
||||
if (!m_reminderManager)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("提醒功能初始化失败。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_reminderManager->updateReminder(reminderId, title, remindAt, recurrence, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedItem != nullptr)
|
||||
{
|
||||
const QVector<ReminderItem> reminders = m_reminderManager->allReminders();
|
||||
bool found = false;
|
||||
for (const ReminderItem &item : reminders)
|
||||
{
|
||||
if (item.id == reminderId)
|
||||
{
|
||||
*updatedItem = item;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("提醒已更新,但没有找到更新后的记录。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[this](QString *errorMessage) {
|
||||
return m_reminderManager && m_reminderManager->pruneFinishedReminders(20, errorMessage);
|
||||
},
|
||||
[this](const QString &soundId, double volume) {
|
||||
if (m_reminderSoundPlayer)
|
||||
{
|
||||
m_reminderSoundPlayer->play(soundId, volume);
|
||||
}
|
||||
},
|
||||
this);
|
||||
centerDialogOnScreen(&dialog, this);
|
||||
if (dialog.exec() != QDialog::Accepted)
|
||||
{
|
||||
@@ -336,11 +466,37 @@ void PetWindow::openSettingsDialog()
|
||||
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
|
||||
}
|
||||
|
||||
applyAppConfig(dialog.appConfig());
|
||||
AppConfig acceptedAppConfig = dialog.appConfig();
|
||||
QString startupError;
|
||||
if (!StartupManager::setLaunchAtStartupEnabled(acceptedAppConfig.launchAtStartup, &startupError))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to update startup setting: ") + startupError);
|
||||
acceptedAppConfig.launchAtStartup = StartupManager::isLaunchAtStartupEnabled();
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("开机自启动"),
|
||||
startupError.isEmpty() ? QStringLiteral("开机自启动设置保存失败。") : startupError);
|
||||
}
|
||||
|
||||
applyAppConfig(acceptedAppConfig);
|
||||
if (!configManager.saveAppConfig(currentAppConfig()))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to save app config from settings dialog."));
|
||||
}
|
||||
|
||||
QString saveWeatherConfigError;
|
||||
if (!weatherStore.save(dialog.weatherConfig(), &saveWeatherConfigError))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to save weather config from settings dialog: ") + saveWeatherConfigError);
|
||||
}
|
||||
|
||||
QString saveWebConfigError;
|
||||
if (!webStore.save(dialog.webConfig(), &saveWebConfigError))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to save web config from settings dialog: ") + saveWebConfigError);
|
||||
}
|
||||
refreshChatInputWebToggle();
|
||||
|
||||
}
|
||||
|
||||
void PetWindow::activateFromExternalInstance()
|
||||
@@ -365,6 +521,10 @@ void PetWindow::activateFromExternalInstance()
|
||||
|
||||
raise();
|
||||
activateWindow();
|
||||
if (m_reminderManager)
|
||||
{
|
||||
m_reminderManager->checkDueRemindersNow();
|
||||
}
|
||||
updateBubblePosition();
|
||||
}
|
||||
|
||||
@@ -373,6 +533,14 @@ void PetWindow::setSettingsFallbackInContextMenuEnabled(bool enabled)
|
||||
m_settingsFallbackInContextMenuEnabled = enabled;
|
||||
}
|
||||
|
||||
void PetWindow::setTrayNotificationCallback(std::function<bool(const QString &, const QString &)> callback)
|
||||
{
|
||||
if (m_notificationDispatcher)
|
||||
{
|
||||
m_notificationDispatcher->setShowCallback(std::move(callback));
|
||||
}
|
||||
}
|
||||
|
||||
void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
{
|
||||
resetBubbleAutoHideTimer();
|
||||
@@ -387,7 +555,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
||||
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
|
||||
chatAction->setEnabled(!aiRequestRunning);
|
||||
QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话"));
|
||||
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消 AI 请求"));
|
||||
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消当前请求"));
|
||||
cancelAIAction->setEnabled(aiRequestRunning);
|
||||
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
|
||||
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
|
||||
@@ -449,30 +617,573 @@ void PetWindow::startChat()
|
||||
return;
|
||||
}
|
||||
|
||||
refreshChatInputWebToggle();
|
||||
m_chatInputDialog->showAt(chatInputAnchorPosition());
|
||||
}
|
||||
|
||||
bool PetWindow::submitChatMessage(const QString &message)
|
||||
bool PetWindow::submitChatMessage(const QString &message, bool webEnabled)
|
||||
{
|
||||
const QString normalizedMessage = message.trimmed();
|
||||
if (normalizedMessage.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedMessage.size() > MaxUserMessageLength)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("消息太长,请控制在 4000 字以内。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
CommandDispatcher dispatcher;
|
||||
const CommandDispatchResult result = dispatcher.dispatch(normalizedMessage);
|
||||
if (result.action == CommandDispatchAction::Reminder)
|
||||
{
|
||||
return handleReminderChatMessage(result.message);
|
||||
}
|
||||
|
||||
if (result.action == CommandDispatchAction::Weather)
|
||||
{
|
||||
return handleWeatherChatMessage(result.message);
|
||||
}
|
||||
|
||||
saveWebTogglePreference(webEnabled);
|
||||
return webEnabled ? submitWebChatMessage(result.message) : submitAiChatMessage(result.message);
|
||||
}
|
||||
|
||||
bool PetWindow::handleReminderChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_reminderManager)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("提醒功能初始化失败。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const ReminderCommandResult result = ReminderCommandHandler::handle(
|
||||
message,
|
||||
*m_reminderManager);
|
||||
playState(result.success ? QStringLiteral("talk") : QStringLiteral("error"), false);
|
||||
showBubbleMessage(result.message);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
|
||||
bool PetWindow::handleWeatherChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_weatherManager)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("天气功能初始化失败。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasActiveAIRequest() || m_streamingChatActive)
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(QStringLiteral("当前请求正在进行,请稍后再查天气。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_weatherManager->isBusy())
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(QStringLiteral("天气查询正在进行,请稍后。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
WeatherStore weatherStore;
|
||||
QString weatherConfigError;
|
||||
const WeatherConfig weatherConfig = weatherStore.load(&weatherConfigError);
|
||||
if (!weatherConfigError.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Weather config load warning: ") + weatherConfigError);
|
||||
}
|
||||
|
||||
stopAnimationPrewarm();
|
||||
playState(QStringLiteral("think"), false);
|
||||
hideReminderActions();
|
||||
showBubbleMessage(QStringLiteral("正在查询天气..."));
|
||||
|
||||
QPointer<PetWindow> window(this);
|
||||
m_weatherManager->queryWeather(message, weatherConfig, [window](const WeatherQueryResult &result) {
|
||||
if (window.isNull())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success)
|
||||
{
|
||||
window->playState(QStringLiteral("talk"), false);
|
||||
window->showBubbleMessage(result.message);
|
||||
window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
window->playState(QStringLiteral("error"), false);
|
||||
window->showBubbleMessage(result.errorMessage.isEmpty() ? QStringLiteral("天气查询失败。") : result.errorMessage);
|
||||
window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
bool PetWindow::submitWebChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_webChatManager)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("联网模式初始化失败。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasActiveWebRequest())
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(QStringLiteral("联网请求正在进行,请稍后。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((m_conversationManager && m_conversationManager->isBusy()) || m_streamingChatActive)
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(QStringLiteral("AI 回复正在进行,请稍后再使用联网模式。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
ConfigManager configManager;
|
||||
AIConfig aiConfig = configManager.loadAIConfig();
|
||||
|
||||
WebStore webStore;
|
||||
QString webConfigError;
|
||||
WebConfig webConfig = webStore.load(&webConfigError);
|
||||
if (!webConfigError.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Web config load warning: ") + webConfigError);
|
||||
}
|
||||
|
||||
const WebCapability capability = WebCapabilityDetector::detect(aiConfig, webConfig);
|
||||
if (!capability.supported)
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(capability.userMessage);
|
||||
refreshChatInputWebToggle();
|
||||
return false;
|
||||
}
|
||||
|
||||
QString runtimeError;
|
||||
AIConfig runtimeConfig = aiConfig;
|
||||
if (!AIProviderFactory::prepareRuntimeConfig(runtimeConfig, &runtimeError))
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(runtimeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString chatMessage = message.trimmed();
|
||||
if (chatMessage.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_conversationManager)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("AI 对话功能初始化失败。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_conversationManager->setConversationMetadata(runtimeConfig.provider, runtimeConfig.model);
|
||||
ChatRequest request = m_conversationManager->buildRequestForUserMessage(chatMessage);
|
||||
if (request.messages.isEmpty())
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("联网请求内容为空。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
stopAnimationPrewarm();
|
||||
playState(QStringLiteral("think"), false);
|
||||
hideReminderActions();
|
||||
showBubbleMessage(QStringLiteral("正在联网思考..."));
|
||||
|
||||
QPointer<PetWindow> window(this);
|
||||
WebChatRequest webRequest;
|
||||
webRequest.chatRequest = request;
|
||||
webRequest.aiConfig = runtimeConfig;
|
||||
webRequest.webConfig = webConfig;
|
||||
m_webChatManager->sendWebChat(webRequest, [window, chatMessage](const WebChatResponse &response) {
|
||||
if (window.isNull())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.success)
|
||||
{
|
||||
window->playState(QStringLiteral("error"), false);
|
||||
window->showBubbleMessage(response.errorMessage.isEmpty()
|
||||
? QStringLiteral("联网请求失败。")
|
||||
: response.errorMessage);
|
||||
window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
window->playState(QStringLiteral("talk"), false);
|
||||
const QString displayText = window->formatWebChatResponseForDisplay(response);
|
||||
if (window->m_conversationManager)
|
||||
{
|
||||
window->m_conversationManager->appendExternalExchange(chatMessage, displayText);
|
||||
}
|
||||
window->saveConversationHistoryIfNeeded();
|
||||
window->refreshChatHistoryPanel();
|
||||
window->showBubbleMessage(displayText);
|
||||
window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PetWindow::hasActiveWebRequest() const
|
||||
{
|
||||
return m_webChatManager && m_webChatManager->isBusy();
|
||||
}
|
||||
|
||||
QString PetWindow::formatWebChatResponseForDisplay(const WebChatResponse &response) const
|
||||
{
|
||||
QString message = response.content.trimmed();
|
||||
if (message.isEmpty())
|
||||
{
|
||||
message = QStringLiteral("联网请求已完成,但没有返回内容。");
|
||||
}
|
||||
|
||||
WebStore webStore;
|
||||
const WebConfig webConfig = webStore.load();
|
||||
if (!webConfig.showCitations)
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
if (!response.citations.isEmpty())
|
||||
{
|
||||
message += QStringLiteral("\n\n来源:\n") + webCitationListText(response.citations);
|
||||
}
|
||||
else if (!response.usedWeb)
|
||||
{
|
||||
message += QStringLiteral("\n\n(模型未使用联网来源)");
|
||||
}
|
||||
else
|
||||
{
|
||||
message += QStringLiteral("\n\n(模型使用了联网能力,但未返回可展示来源)");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
void PetWindow::refreshChatInputWebToggle()
|
||||
{
|
||||
if (!m_chatInputDialog)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WebStore webStore;
|
||||
QString errorMessage;
|
||||
const WebConfig webConfig = webStore.load(&errorMessage);
|
||||
if (!errorMessage.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Web config load warning: ") + errorMessage);
|
||||
}
|
||||
|
||||
ConfigManager configManager;
|
||||
const AIConfig aiConfig = configManager.loadAIConfig();
|
||||
const WebCapability capability = WebCapabilityDetector::detect(aiConfig, webConfig);
|
||||
const bool available = webConfig.enabled;
|
||||
m_chatInputDialog->setWebToggleAvailable(available, capability.userMessage);
|
||||
const bool checked = webConfig.rememberLastToggle ? webConfig.lastToggleOn : webConfig.defaultToggleOn;
|
||||
m_chatInputDialog->setWebEnabled(available && checked);
|
||||
}
|
||||
|
||||
void PetWindow::saveWebTogglePreference(bool webEnabled)
|
||||
{
|
||||
WebStore webStore;
|
||||
QString loadError;
|
||||
WebConfig webConfig = webStore.load(&loadError);
|
||||
if (!loadError.isEmpty())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Web config load warning: ") + loadError);
|
||||
}
|
||||
|
||||
if (!webConfig.rememberLastToggle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webConfig.lastToggleOn = webEnabled;
|
||||
QString saveError;
|
||||
if (!webStore.save(webConfig, &saveError))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to save web toggle preference: ") + saveError);
|
||||
}
|
||||
}
|
||||
|
||||
void PetWindow::handleTriggeredReminder(const ReminderItem &item)
|
||||
{
|
||||
playReminderSound();
|
||||
|
||||
if (shouldNotifyOnlyForReminder())
|
||||
{
|
||||
showReminderNotification(item);
|
||||
return;
|
||||
}
|
||||
|
||||
enqueueVisibleTriggeredReminder(item);
|
||||
}
|
||||
|
||||
void PetWindow::playReminderSound()
|
||||
{
|
||||
if (m_appConfig.reminderSoundEnabled && m_reminderSoundPlayer)
|
||||
{
|
||||
m_reminderSoundPlayer->play(m_appConfig.reminderSoundId, m_appConfig.reminderSoundVolume);
|
||||
}
|
||||
}
|
||||
|
||||
void PetWindow::showReminderNotification(const ReminderItem &item)
|
||||
{
|
||||
if (m_notificationDispatcher)
|
||||
{
|
||||
const bool shown = m_notificationDispatcher->showReminder(QStringLiteral("定时提醒"), QStringLiteral("到时间啦:%1").arg(item.title));
|
||||
if (!shown)
|
||||
{
|
||||
Logger::warning(QStringLiteral("Reminder notification backend unavailable: id=%1").arg(item.id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::warning(QStringLiteral("Reminder notification dispatcher is unavailable: id=%1").arg(item.id));
|
||||
}
|
||||
|
||||
bool PetWindow::shouldNotifyOnlyForReminder() const
|
||||
{
|
||||
return !isVisible() || hasActiveAIRequest() || m_streamingChatActive;
|
||||
}
|
||||
|
||||
void PetWindow::enqueueVisibleTriggeredReminder(const ReminderItem &item)
|
||||
{
|
||||
m_pendingVisibleTriggeredReminders.append(item);
|
||||
showNextTriggeredReminder();
|
||||
}
|
||||
|
||||
void PetWindow::showNextTriggeredReminder()
|
||||
{
|
||||
if (m_hasActiveTriggeredReminder || m_dragging || m_pendingVisibleTriggeredReminders.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const ReminderItem item = m_pendingVisibleTriggeredReminders.takeFirst();
|
||||
showTriggeredReminder(item);
|
||||
}
|
||||
|
||||
void PetWindow::finishActiveTriggeredReminder(bool hideBubble)
|
||||
{
|
||||
hideReminderActions();
|
||||
m_hasActiveTriggeredReminder = false;
|
||||
if (hideBubble && m_chatBubble)
|
||||
{
|
||||
m_chatBubble->hideBubble();
|
||||
}
|
||||
|
||||
showNextTriggeredReminder();
|
||||
}
|
||||
|
||||
void PetWindow::showTriggeredReminder(const ReminderItem &item)
|
||||
{
|
||||
const QString reminderState = m_clips.contains(QStringLiteral("happy"))
|
||||
? QStringLiteral("happy")
|
||||
: QStringLiteral("talk");
|
||||
playState(reminderState, false);
|
||||
hideReminderActions();
|
||||
m_chatBubble->showMessage(QStringLiteral("到时间啦:%1").arg(item.title), bubbleAnchorPosition(), 0);
|
||||
showReminderActions(item);
|
||||
}
|
||||
|
||||
void PetWindow::ensureReminderActionPanel()
|
||||
{
|
||||
if (m_reminderActionPanel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto *panel = new QFrame();
|
||||
panel->setObjectName(QStringLiteral("ReminderActionPanel"));
|
||||
panel->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
||||
panel->setAttribute(Qt::WA_TranslucentBackground);
|
||||
panel->setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
panel->setStyleSheet(QStringLiteral(
|
||||
"QFrame#ReminderActionPanel {"
|
||||
"background: #ffffff;"
|
||||
"border: 1px solid #c7cdd4;"
|
||||
"border-radius: 8px;"
|
||||
"}"
|
||||
"QPushButton {"
|
||||
"background: #f1f4f7;"
|
||||
"border: 1px solid #aeb6bf;"
|
||||
"border-radius: 6px;"
|
||||
"padding: 7px 12px;"
|
||||
"color: #202124;"
|
||||
"}"
|
||||
"QPushButton:hover {"
|
||||
"background: #e4e9ee;"
|
||||
"}"
|
||||
"QPushButton:pressed {"
|
||||
"background: #d5dce3;"
|
||||
"}"));
|
||||
|
||||
auto *layout = new QVBoxLayout(panel);
|
||||
layout->setContentsMargins(8, 8, 8, 8);
|
||||
layout->setSpacing(8);
|
||||
|
||||
auto *dismissButton = new QPushButton(QStringLiteral("知道了"), panel);
|
||||
auto *snoozeButton = new QPushButton(QStringLiteral("5分钟后再提醒"), panel);
|
||||
layout->addWidget(dismissButton);
|
||||
layout->addWidget(snoozeButton);
|
||||
|
||||
connect(dismissButton, &QPushButton::clicked, this, [this]() {
|
||||
finishActiveTriggeredReminder(true);
|
||||
});
|
||||
|
||||
connect(snoozeButton, &QPushButton::clicked, this, [this]() {
|
||||
if (!m_hasActiveTriggeredReminder)
|
||||
{
|
||||
finishActiveTriggeredReminder(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const ReminderItem item = m_activeTriggeredReminder;
|
||||
hideReminderActions();
|
||||
snoozeTriggeredReminder(item);
|
||||
});
|
||||
|
||||
m_reminderActionPanel.reset(panel);
|
||||
}
|
||||
|
||||
void PetWindow::showReminderActions(const ReminderItem &item)
|
||||
{
|
||||
m_activeTriggeredReminder = item;
|
||||
m_hasActiveTriggeredReminder = true;
|
||||
ensureReminderActionPanel();
|
||||
if (!m_reminderActionPanel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_reminderActionPanel->adjustSize();
|
||||
updateReminderActionPosition();
|
||||
m_reminderActionPanel->show();
|
||||
m_reminderActionPanel->raise();
|
||||
}
|
||||
|
||||
void PetWindow::hideReminderActions()
|
||||
{
|
||||
m_hasActiveTriggeredReminder = false;
|
||||
if (m_reminderActionPanel)
|
||||
{
|
||||
m_reminderActionPanel->hide();
|
||||
}
|
||||
}
|
||||
|
||||
void PetWindow::updateReminderActionPosition()
|
||||
{
|
||||
if (!m_reminderActionPanel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_reminderActionPanel->adjustSize();
|
||||
const QSize panelSize = m_reminderActionPanel->sizeHint();
|
||||
const QRect petGeometry = frameGeometry();
|
||||
constexpr int PanelSideSpacing = 8;
|
||||
|
||||
QPoint position(
|
||||
petGeometry.right() + PanelSideSpacing,
|
||||
petGeometry.center().y() - panelSize.height() / 2);
|
||||
|
||||
if (QScreen *screen = screenForPopup(this))
|
||||
{
|
||||
const QRect availableGeometry = screen->availableGeometry();
|
||||
const int maxX = qMax(availableGeometry.left(), availableGeometry.right() - panelSize.width());
|
||||
const int maxY = qMax(availableGeometry.top(), availableGeometry.bottom() - panelSize.height());
|
||||
if (position.x() > maxX)
|
||||
{
|
||||
position.setX(petGeometry.left() - panelSize.width() - PanelSideSpacing);
|
||||
}
|
||||
|
||||
position.setX(qBound(
|
||||
availableGeometry.left(),
|
||||
position.x(),
|
||||
maxX));
|
||||
position.setY(qBound(
|
||||
availableGeometry.top(),
|
||||
position.y(),
|
||||
maxY));
|
||||
}
|
||||
|
||||
m_reminderActionPanel->move(position);
|
||||
}
|
||||
|
||||
void PetWindow::snoozeTriggeredReminder(const ReminderItem &item)
|
||||
{
|
||||
m_hasActiveTriggeredReminder = false;
|
||||
if (!m_reminderManager)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("提醒功能初始化失败。"));
|
||||
QTimer::singleShot(1200, this, [this]() {
|
||||
showNextTriggeredReminder();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ReminderItem snoozedItem;
|
||||
QString errorMessage;
|
||||
if (!m_reminderManager->snoozeReminder(item, 5, &snoozedItem, &errorMessage))
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(errorMessage.isEmpty() ? QStringLiteral("创建稍后提醒失败。") : errorMessage);
|
||||
QTimer::singleShot(1200, this, [this]() {
|
||||
showNextTriggeredReminder();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(QStringLiteral("已延后提醒:%1,时间:%2").arg(snoozedItem.title, reminderDisplayTime(snoozedItem.remindAt)));
|
||||
QTimer::singleShot(1200, this, [this]() {
|
||||
showNextTriggeredReminder();
|
||||
});
|
||||
}
|
||||
|
||||
bool PetWindow::submitAiChatMessage(const QString &message)
|
||||
{
|
||||
if (hasActiveWebRequest())
|
||||
{
|
||||
showBubbleMessage(QStringLiteral("当前联网请求正在进行,请稍后。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_conversationManager || m_conversationManager->isBusy())
|
||||
{
|
||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString trimmedMessage = message.trimmed();
|
||||
if (trimmedMessage.isEmpty())
|
||||
const QString chatMessage = message.trimmed();
|
||||
if (chatMessage.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmedMessage.size() > MaxUserMessageLength)
|
||||
{
|
||||
playState(QStringLiteral("error"), false);
|
||||
showBubbleMessage(QStringLiteral("消息太长,请控制在 4000 字以内。"));
|
||||
return false;
|
||||
}
|
||||
|
||||
ConfigManager configManager;
|
||||
AIConfig config = configManager.loadAIConfig();
|
||||
QString errorMessage;
|
||||
@@ -491,6 +1202,7 @@ bool PetWindow::submitChatMessage(const QString &message)
|
||||
return false;
|
||||
}
|
||||
|
||||
m_conversationManager->setConversationMetadata(config.provider, config.model);
|
||||
if (!m_conversationManager->setProvider(std::move(provider)))
|
||||
{
|
||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||
@@ -499,6 +1211,7 @@ bool PetWindow::submitChatMessage(const QString &message)
|
||||
|
||||
stopAnimationPrewarm();
|
||||
playState(QStringLiteral("think"), false);
|
||||
hideReminderActions();
|
||||
m_streamingAssistantText.clear();
|
||||
m_streamBubbleUpdateTimer.stop();
|
||||
m_streamingChatActive = true;
|
||||
@@ -507,7 +1220,7 @@ bool PetWindow::submitChatMessage(const QString &message)
|
||||
|
||||
QPointer<PetWindow> window(this);
|
||||
m_conversationManager->sendUserMessageStreaming(
|
||||
trimmedMessage,
|
||||
chatMessage,
|
||||
[window](const QString &delta) {
|
||||
if (!window.isNull())
|
||||
{
|
||||
@@ -564,9 +1277,13 @@ void PetWindow::clearConversation()
|
||||
Logger::warning(QStringLiteral("Failed to clear persisted conversation history."));
|
||||
}
|
||||
cancelStreamingChat();
|
||||
if (m_webChatManager && m_webChatManager->isBusy())
|
||||
{
|
||||
m_webChatManager->cancel();
|
||||
}
|
||||
refreshChatHistoryPanel();
|
||||
showBubbleMessage(hadActiveRequest
|
||||
? QStringLiteral("已取消 AI 请求,并清空对话。")
|
||||
? QStringLiteral("已取消当前请求,并清空对话。")
|
||||
: QStringLiteral("对话已清空。"));
|
||||
playState(QStringLiteral("idle"), false);
|
||||
}
|
||||
@@ -582,12 +1299,21 @@ void PetWindow::cancelActiveAIRequest()
|
||||
return;
|
||||
}
|
||||
|
||||
showBubbleMessage(QStringLiteral("没有正在进行的 AI 请求。"));
|
||||
if (m_webChatManager && m_webChatManager->isBusy())
|
||||
{
|
||||
m_webChatManager->cancel();
|
||||
showBubbleMessage(QStringLiteral("联网请求已取消。"));
|
||||
playState(QStringLiteral("idle"), false);
|
||||
return;
|
||||
}
|
||||
|
||||
showBubbleMessage(QStringLiteral("没有正在进行的 AI 或联网请求。"));
|
||||
}
|
||||
|
||||
bool PetWindow::hasActiveAIRequest() const
|
||||
{
|
||||
return m_conversationManager && m_conversationManager->isBusy();
|
||||
return (m_conversationManager && m_conversationManager->isBusy())
|
||||
|| hasActiveWebRequest();
|
||||
}
|
||||
|
||||
bool PetWindow::isManualStateSwitchLocked() const
|
||||
@@ -723,6 +1449,7 @@ void PetWindow::flushStreamingBubble(bool finalUpdate)
|
||||
return;
|
||||
}
|
||||
|
||||
hideReminderActions();
|
||||
m_chatBubble->showMessage(
|
||||
m_streamingAssistantText,
|
||||
bubbleAnchorPosition(),
|
||||
@@ -767,6 +1494,7 @@ void PetWindow::hideEvent(QHideEvent *event)
|
||||
{
|
||||
m_chatBubble->hideBubble();
|
||||
}
|
||||
hideReminderActions();
|
||||
if (m_chatInputDialog)
|
||||
{
|
||||
m_chatInputDialog->hide();
|
||||
@@ -787,6 +1515,11 @@ void PetWindow::hideEvent(QHideEvent *event)
|
||||
void PetWindow::showEvent(QShowEvent *event)
|
||||
{
|
||||
QWidget::showEvent(event);
|
||||
if (m_reminderManager)
|
||||
{
|
||||
m_reminderManager->start();
|
||||
m_reminderManager->checkDueRemindersNow();
|
||||
}
|
||||
scheduleAnimationPrewarm();
|
||||
}
|
||||
|
||||
@@ -851,6 +1584,7 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
|
||||
m_dragging = false;
|
||||
playResolvedState(m_stateMachine.endDrag(), false);
|
||||
scheduleAnimationPrewarm();
|
||||
showNextTriggeredReminder();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
@@ -956,6 +1690,7 @@ void PetWindow::addStateTestActions(QMenu *menu)
|
||||
void PetWindow::updateBubblePosition()
|
||||
{
|
||||
m_chatBubble->updateAnchorPosition(bubbleAnchorPosition());
|
||||
updateReminderActionPosition();
|
||||
}
|
||||
|
||||
QPoint PetWindow::bubbleAnchorPosition() const
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@
|
||||
#include "../character/CharacterPackage.h"
|
||||
#include "../character/FrameAnimator.h"
|
||||
#include "../config/AppConfig.h"
|
||||
#include "../reminder/ReminderTypes.h"
|
||||
#include "../web/WebChatTypes.h"
|
||||
#include "../state/PetStateMachine.h"
|
||||
|
||||
#include <QMap>
|
||||
@@ -11,9 +13,11 @@
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class QMenu;
|
||||
@@ -26,7 +30,12 @@ class ChatHistoryPanel;
|
||||
class ChatInputDialog;
|
||||
class ConversationManager;
|
||||
class ConversationStore;
|
||||
class NotificationDispatcher;
|
||||
class PetView;
|
||||
class ReminderManager;
|
||||
class ReminderSoundPlayer;
|
||||
class WebChatManager;
|
||||
class WeatherManager;
|
||||
|
||||
class PetWindow : public QWidget
|
||||
{
|
||||
@@ -39,6 +48,7 @@ public:
|
||||
void openSettingsDialog();
|
||||
void activateFromExternalInstance();
|
||||
void setSettingsFallbackInContextMenuEnabled(bool enabled);
|
||||
void setTrayNotificationCallback(std::function<bool(const QString &, const QString &)> callback);
|
||||
void pauseAnimation();
|
||||
void resumeAnimation();
|
||||
void showBubbleMessage(const QString &message);
|
||||
@@ -59,7 +69,28 @@ private:
|
||||
void buildAnimationClips();
|
||||
void addStateTestActions(QMenu *menu);
|
||||
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 cancelActiveAIRequest();
|
||||
void showConversationHistory();
|
||||
@@ -107,6 +138,12 @@ private:
|
||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||
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;
|
||||
QTimer m_idleBehaviorTimer;
|
||||
QTimer m_behaviorReturnTimer;
|
||||
@@ -122,6 +159,8 @@ private:
|
||||
QPoint m_dragOffset;
|
||||
QString m_streamingAssistantText;
|
||||
QStringList m_animationPrewarmQueue;
|
||||
QVector<ReminderItem> m_pendingVisibleTriggeredReminders;
|
||||
ReminderItem m_activeTriggeredReminder;
|
||||
qint64 m_clipAccessSerial = 0;
|
||||
bool m_dragging;
|
||||
bool m_alwaysOnTop;
|
||||
@@ -130,4 +169,5 @@ private:
|
||||
bool m_streamingChatActive = false;
|
||||
bool m_streamingTalkStarted = false;
|
||||
bool m_settingsFallbackInContextMenuEnabled = true;
|
||||
bool m_hasActiveTriggeredReminder = false;
|
||||
};
|
||||
|
||||
+1442
-1
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,13 @@
|
||||
|
||||
#include "../config/AIConfig.h"
|
||||
#include "../config/AppConfig.h"
|
||||
#include "../ai/LLMTypes.h"
|
||||
#include "../reminder/ReminderTypes.h"
|
||||
#include "../web/WebConfig.h"
|
||||
#include "../weather/WeatherConfig.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QVector>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
@@ -13,9 +18,12 @@ class QComboBox;
|
||||
class QDoubleSpinBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
class LLMProvider;
|
||||
class WebChatManager;
|
||||
class WeatherManager;
|
||||
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
@@ -23,13 +31,23 @@ public:
|
||||
explicit SettingsDialog(
|
||||
const AIConfigStore &configStore,
|
||||
const AppConfig &appConfig,
|
||||
const WeatherConfig &weatherConfig,
|
||||
const WebConfig &webConfig,
|
||||
const QVector<ChatMessage> &conversationHistory,
|
||||
const QVector<ReminderItem> &reminders,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
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);
|
||||
~SettingsDialog() override;
|
||||
|
||||
AIConfigStore aiConfigStore() const;
|
||||
AppConfig appConfig() const;
|
||||
WeatherConfig weatherConfig() const;
|
||||
WebConfig webConfig() const;
|
||||
|
||||
protected:
|
||||
void accept() override;
|
||||
@@ -45,9 +63,29 @@ private:
|
||||
void testConnection();
|
||||
void setTestStatus(const QString &message, const QString &state);
|
||||
void clearConversationHistory();
|
||||
void reloadConversationHistoryList();
|
||||
QVector<ChatMessage> filteredConversationHistory() const;
|
||||
void exportConversationHistoryMarkdown();
|
||||
void exportConversationHistoryJson();
|
||||
void reloadCharacterList(const QString &selectedCharacterId = {});
|
||||
void importCharacterFolder();
|
||||
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;
|
||||
QLineEdit *m_baseUrlEdit = nullptr;
|
||||
@@ -60,6 +98,7 @@ private:
|
||||
QPushButton *m_testConnectionButton = nullptr;
|
||||
QLabel *m_testStatusLabel = nullptr;
|
||||
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
|
||||
QCheckBox *m_launchAtStartupCheckBox = nullptr;
|
||||
QSpinBox *m_scaleSpinBox = nullptr;
|
||||
QComboBox *m_performanceModeComboBox = nullptr;
|
||||
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
|
||||
@@ -73,16 +112,60 @@ private:
|
||||
QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr;
|
||||
QPushButton *m_clearConversationHistoryButton = 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;
|
||||
QPushButton *m_importCharacterButton = nullptr;
|
||||
QPushButton *m_deleteCharacterButton = nullptr;
|
||||
QPushButton *m_exportCharacterButton = nullptr;
|
||||
QPushButton *m_openUserCharacterDirButton = 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_acceptedConfigStore;
|
||||
AppConfig m_appConfig;
|
||||
WeatherConfig m_weatherConfig;
|
||||
WebConfig m_webConfig;
|
||||
QVector<ChatMessage> m_conversationHistory;
|
||||
QVector<ReminderItem> m_reminders;
|
||||
QString m_currentProvider;
|
||||
std::function<bool()> m_aiTestBlocked;
|
||||
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<WebChatManager> m_webTestManager;
|
||||
std::unique_ptr<WeatherManager> m_weatherTestManager;
|
||||
bool m_hasAcceptedConfigStore = false;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
const QString cleanedRelativePath = cleanRelativePath(relativePath);
|
||||
@@ -55,12 +44,12 @@ QString ResourcePaths::charactersRootPath()
|
||||
return resourcePath(QStringLiteral("characters"));
|
||||
}
|
||||
|
||||
QString ResourcePaths::reminderSoundsRootPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("sounds/reminders"));
|
||||
}
|
||||
|
||||
QString ResourcePaths::appIconPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("icons/app_icon.ico"));
|
||||
}
|
||||
|
||||
QString ResourcePaths::appIconSourcePngPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("icons/app_icon_1024.png"));
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
class ResourcePaths
|
||||
{
|
||||
public:
|
||||
static QString resourcesRootPath();
|
||||
static QString resourcePath(const QString &relativePath);
|
||||
static QString charactersRootPath();
|
||||
static QString reminderSoundsRootPath();
|
||||
static QString appIconPath();
|
||||
static QString appIconSourcePngPath();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,622 @@
|
||||
#include "WeatherManager.h"
|
||||
|
||||
#include "../util/Logger.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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
QString normalizedCandidateText(const QString &text)
|
||||
{
|
||||
return text.trimmed().toLower();
|
||||
}
|
||||
|
||||
QString candidateKey(const WeatherLocationCandidate &candidate)
|
||||
{
|
||||
return normalizedCandidateText(candidate.cityName)
|
||||
+ QLatin1Char('|')
|
||||
+ normalizedCandidateText(candidate.adminName)
|
||||
+ QLatin1Char('|')
|
||||
+ normalizedCandidateText(candidate.countryName);
|
||||
}
|
||||
|
||||
bool hasCandidateAmbiguity(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;
|
||||
}
|
||||
|
||||
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();
|
||||
candidates.append(candidate);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
}
|
||||
|
||||
WeatherManager::WeatherManager()
|
||||
{
|
||||
m_timeoutTimer.setSingleShot(true);
|
||||
QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() {
|
||||
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;
|
||||
clearReply();
|
||||
m_busy = false;
|
||||
}
|
||||
|
||||
void WeatherManager::startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly)
|
||||
{
|
||||
m_location = {};
|
||||
m_location.source = source;
|
||||
m_locationTestOnly = locationTestOnly;
|
||||
|
||||
QUrl url(QStringLiteral("https://geocoding-api.open-meteo.com/v1/search"));
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("name"), cityName);
|
||||
query.addQueryItem(QStringLiteral("count"), QString::number(GeocodingCandidateCount));
|
||||
query.addQueryItem(QStringLiteral("language"), m_config.language);
|
||||
query.addQueryItem(QStringLiteral("format"), QStringLiteral("json"));
|
||||
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;
|
||||
|
||||
QUrl url(QStringLiteral("https://ipapi.co/json/"));
|
||||
startRequest(url, [this](QNetworkReply *reply) {
|
||||
finishIpLocationRequest(reply);
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
finishGeocodingWithError(QStringLiteral("城市解析失败:%1。").arg(reply->errorString()));
|
||||
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 = candidatesFromGeocodingResults(results);
|
||||
if (m_locationCandidates.isEmpty())
|
||||
{
|
||||
finishGeocodingWithError(QStringLiteral("没有找到匹配的城市。"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_hasLocationAmbiguity = hasCandidateAmbiguity(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)
|
||||
{
|
||||
const QByteArray body = reply->readAll();
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
finishWithError(QStringLiteral("公网 IP 定位失败:%1。").arg(reply->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
finishWithError(QStringLiteral("公网 IP 定位结果格式无效。"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = document.object();
|
||||
const bool hasLatitude = jsonDouble(root, QStringLiteral("latitude"), &m_location.latitude);
|
||||
const bool hasLongitude = jsonDouble(root, QStringLiteral("longitude"), &m_location.longitude);
|
||||
m_location.cityName = root.value(QStringLiteral("city")).toString();
|
||||
m_location.adminName = root.value(QStringLiteral("region")).toString();
|
||||
m_location.countryName = root.value(QStringLiteral("country_name")).toString();
|
||||
m_location.timezone = root.value(QStringLiteral("timezone")).toString();
|
||||
m_location.source = WeatherLocationSource::IpFallback;
|
||||
if (!hasLatitude || !hasLongitude || m_location.cityName.trimmed().isEmpty())
|
||||
{
|
||||
finishWithError(QStringLiteral("公网 IP 定位没有返回有效城市或经纬度。"));
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::info(QStringLiteral("Weather IP location succeeded."));
|
||||
startForecastRequest();
|
||||
}
|
||||
|
||||
void WeatherManager::finishForecastRequest(QNetworkReply *reply)
|
||||
{
|
||||
const QByteArray body = reply->readAll();
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
finishWithError(QStringLiteral("天气数据请求失败:%1。").arg(reply->errorString()));
|
||||
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)
|
||||
{
|
||||
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(m_config.timeoutMs);
|
||||
}
|
||||
|
||||
void WeatherManager::finishWithError(const QString &message)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "WeatherParser.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 startForecastRequest();
|
||||
void finishGeocodingRequest(QNetworkReply *reply);
|
||||
void finishIpLocationRequest(QNetworkReply *reply);
|
||||
void finishForecastRequest(QNetworkReply *reply);
|
||||
void startRequest(const QUrl &url, std::function<void(QNetworkReply *)> finishedHandler);
|
||||
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;
|
||||
WeatherLocation m_location;
|
||||
QVector<WeatherLocationCandidate> m_locationCandidates;
|
||||
bool m_locationTestOnly = false;
|
||||
bool m_hasLocationAmbiguity = false;
|
||||
bool m_busy = false;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "WeatherTypes.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
class WeatherParser
|
||||
{
|
||||
public:
|
||||
WeatherQuery parse(const QString &text) const;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
#include "WeatherSummaryFormatter.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString locationDisplayName(const WeatherLocation &location)
|
||||
{
|
||||
QStringList parts;
|
||||
if (!location.cityName.trimmed().isEmpty())
|
||||
{
|
||||
parts.append(location.cityName.trimmed());
|
||||
}
|
||||
if (!location.adminName.trimmed().isEmpty() && location.adminName.trimmed() != location.cityName.trimmed())
|
||||
{
|
||||
parts.append(location.adminName.trimmed());
|
||||
}
|
||||
if (!location.countryName.trimmed().isEmpty() && location.countryName.trimmed() != location.adminName.trimmed())
|
||||
{
|
||||
parts.append(location.countryName.trimmed());
|
||||
}
|
||||
return parts.isEmpty() ? QStringLiteral("当前位置") : parts.join(QStringLiteral(","));
|
||||
}
|
||||
|
||||
QString candidateDisplayName(const WeatherLocationCandidate &candidate)
|
||||
{
|
||||
QStringList parts;
|
||||
if (!candidate.cityName.trimmed().isEmpty())
|
||||
{
|
||||
parts.append(candidate.cityName.trimmed());
|
||||
}
|
||||
if (!candidate.adminName.trimmed().isEmpty() && candidate.adminName.trimmed() != candidate.cityName.trimmed())
|
||||
{
|
||||
parts.append(candidate.adminName.trimmed());
|
||||
}
|
||||
if (!candidate.countryName.trimmed().isEmpty() && candidate.countryName.trimmed() != candidate.adminName.trimmed())
|
||||
{
|
||||
parts.append(candidate.countryName.trimmed());
|
||||
}
|
||||
return parts.isEmpty() ? QStringLiteral("未知地点") : parts.join(QStringLiteral(","));
|
||||
}
|
||||
|
||||
QString sourcePrefix(const WeatherLocation &location)
|
||||
{
|
||||
switch (location.source)
|
||||
{
|
||||
case WeatherLocationSource::SettingsDefault:
|
||||
return QStringLiteral("使用设置页默认城市:%1。\n").arg(locationDisplayName(location));
|
||||
case WeatherLocationSource::IpFallback:
|
||||
return QStringLiteral("根据公网 IP 判断城市为:%1。\n").arg(locationDisplayName(location));
|
||||
case WeatherLocationSource::ExplicitCity:
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
QString ambiguityPrefix(const WeatherReport &report)
|
||||
{
|
||||
if (!report.hasLocationAmbiguity || report.locationCandidates.size() <= 1)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
QStringList otherCandidates;
|
||||
const int maxOtherCandidateCount = qMin(3, report.locationCandidates.size() - 1);
|
||||
for (int index = 1; index <= maxOtherCandidateCount; ++index)
|
||||
{
|
||||
otherCandidates.append(candidateDisplayName(report.locationCandidates.at(index)));
|
||||
}
|
||||
|
||||
if (otherCandidates.isEmpty())
|
||||
{
|
||||
return QStringLiteral("可能存在同名城市,当前使用:%1。\n").arg(locationDisplayName(report.location));
|
||||
}
|
||||
|
||||
return QStringLiteral("可能存在同名城市,当前使用:%1。其他候选包括:%2。\n")
|
||||
.arg(locationDisplayName(report.location), otherCandidates.join(QStringLiteral(";")));
|
||||
}
|
||||
|
||||
QString temperatureText(double value)
|
||||
{
|
||||
return QStringLiteral("%1℃").arg(QString::number(value, 'f', 1));
|
||||
}
|
||||
|
||||
QString currentText(const WeatherReport &report)
|
||||
{
|
||||
const WeatherCurrent ¤t = report.current;
|
||||
QStringList details;
|
||||
if (current.hasTemperature)
|
||||
{
|
||||
details.append(QStringLiteral("温度 %1").arg(temperatureText(current.temperatureC)));
|
||||
}
|
||||
if (current.hasApparentTemperature)
|
||||
{
|
||||
details.append(QStringLiteral("体感 %1").arg(temperatureText(current.apparentTemperatureC)));
|
||||
}
|
||||
if (current.hasHumidity)
|
||||
{
|
||||
details.append(QStringLiteral("湿度 %1%").arg(QString::number(current.humidityPercent, 'f', 0)));
|
||||
}
|
||||
if (current.hasWindSpeed)
|
||||
{
|
||||
const QString direction = current.hasWindDirection
|
||||
? WeatherSummaryFormatter::windDirectionText(current.windDirectionDegree)
|
||||
: QStringLiteral("风");
|
||||
details.append(QStringLiteral("%1 %2 km/h").arg(direction, QString::number(current.windSpeedKmh, 'f', 1)));
|
||||
}
|
||||
if (current.hasPrecipitation)
|
||||
{
|
||||
details.append(QStringLiteral("降水 %1 mm").arg(QString::number(current.precipitationMm, 'f', 1)));
|
||||
}
|
||||
|
||||
QString text = QStringLiteral("%1当前天气:%2")
|
||||
.arg(locationDisplayName(report.location), WeatherSummaryFormatter::weatherCodeText(current.weatherCode));
|
||||
if (!details.isEmpty())
|
||||
{
|
||||
text += QStringLiteral(",") + details.join(QStringLiteral(","));
|
||||
}
|
||||
if (current.time.isValid())
|
||||
{
|
||||
text += QStringLiteral("。更新时间:%1").arg(current.time.toString(QStringLiteral("HH:mm")));
|
||||
}
|
||||
text += QStringLiteral("。");
|
||||
return text;
|
||||
}
|
||||
|
||||
QString dailyText(const WeatherDailyForecast &daily, const QString &prefix)
|
||||
{
|
||||
QStringList details;
|
||||
if (daily.hasTemperatureMax && daily.hasTemperatureMin)
|
||||
{
|
||||
details.append(QStringLiteral("%1-%2").arg(temperatureText(daily.temperatureMinC), temperatureText(daily.temperatureMaxC)));
|
||||
}
|
||||
else if (daily.hasTemperatureMax)
|
||||
{
|
||||
details.append(QStringLiteral("最高 %1").arg(temperatureText(daily.temperatureMaxC)));
|
||||
}
|
||||
else if (daily.hasTemperatureMin)
|
||||
{
|
||||
details.append(QStringLiteral("最低 %1").arg(temperatureText(daily.temperatureMinC)));
|
||||
}
|
||||
if (daily.hasPrecipitationProbability)
|
||||
{
|
||||
details.append(QStringLiteral("降水概率 %1%").arg(QString::number(daily.precipitationProbabilityPercent, 'f', 0)));
|
||||
}
|
||||
|
||||
QString text = QStringLiteral("%1:%2").arg(prefix, WeatherSummaryFormatter::weatherCodeText(daily.weatherCode));
|
||||
if (!details.isEmpty())
|
||||
{
|
||||
text += QStringLiteral(",") + details.join(QStringLiteral(","));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const WeatherDailyForecast *forecastForOffset(const QVector<WeatherDailyForecast> &forecasts, int offset)
|
||||
{
|
||||
if (offset < 0 || offset >= forecasts.size())
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
return &forecasts[offset];
|
||||
}
|
||||
}
|
||||
|
||||
QString WeatherSummaryFormatter::format(const WeatherReport &report) const
|
||||
{
|
||||
QString message = sourcePrefix(report.location) + ambiguityPrefix(report);
|
||||
if (report.query.kind == WeatherQueryKind::Current)
|
||||
{
|
||||
if (!report.current.valid)
|
||||
{
|
||||
return message + QStringLiteral("天气数据缺少当前天气,暂时无法生成结果。");
|
||||
}
|
||||
return message + currentText(report);
|
||||
}
|
||||
|
||||
if (report.query.kind == WeatherQueryKind::Daily)
|
||||
{
|
||||
const WeatherDailyForecast *daily = forecastForOffset(report.dailyForecasts, report.query.dateOffset);
|
||||
if (daily == nullptr)
|
||||
{
|
||||
return message + QStringLiteral("天气数据缺少目标日期预报,暂时无法生成结果。");
|
||||
}
|
||||
const QString dayName = report.query.dateOffset == 0
|
||||
? QStringLiteral("今天")
|
||||
: (report.query.dateOffset == 1 ? QStringLiteral("明天") : QStringLiteral("后天"));
|
||||
return message + QStringLiteral("%1%2天气:%3。")
|
||||
.arg(locationDisplayName(report.location), dayName, dailyText(*daily, daily->date.toString(QStringLiteral("MM-dd"))).section(QStringLiteral(":"), 1));
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
const int count = qMin(report.query.forecastDays, report.dailyForecasts.size());
|
||||
for (int index = 0; index < count; ++index)
|
||||
{
|
||||
const WeatherDailyForecast &daily = report.dailyForecasts.at(index);
|
||||
lines.append(dailyText(daily, daily.date.toString(QStringLiteral("MM-dd"))));
|
||||
}
|
||||
|
||||
if (lines.isEmpty())
|
||||
{
|
||||
return message + QStringLiteral("天气数据缺少未来预报,暂时无法生成结果。");
|
||||
}
|
||||
|
||||
return message
|
||||
+ QStringLiteral("%1未来 %2 天天气:\n%3。")
|
||||
.arg(locationDisplayName(report.location), QString::number(count), lines.join(QStringLiteral("\n")));
|
||||
}
|
||||
|
||||
QString WeatherSummaryFormatter::weatherCodeText(int code)
|
||||
{
|
||||
switch (code)
|
||||
{
|
||||
case 0:
|
||||
return QStringLiteral("晴");
|
||||
case 1:
|
||||
return QStringLiteral("大部晴朗");
|
||||
case 2:
|
||||
return QStringLiteral("局部多云");
|
||||
case 3:
|
||||
return QStringLiteral("阴");
|
||||
case 45:
|
||||
case 48:
|
||||
return QStringLiteral("雾");
|
||||
case 51:
|
||||
case 53:
|
||||
case 55:
|
||||
return QStringLiteral("毛毛雨");
|
||||
case 56:
|
||||
case 57:
|
||||
return QStringLiteral("冻毛毛雨");
|
||||
case 61:
|
||||
return QStringLiteral("小雨");
|
||||
case 63:
|
||||
return QStringLiteral("中雨");
|
||||
case 65:
|
||||
return QStringLiteral("大雨");
|
||||
case 66:
|
||||
case 67:
|
||||
return QStringLiteral("冻雨");
|
||||
case 71:
|
||||
return QStringLiteral("小雪");
|
||||
case 73:
|
||||
return QStringLiteral("中雪");
|
||||
case 75:
|
||||
return QStringLiteral("大雪");
|
||||
case 77:
|
||||
return QStringLiteral("雪粒");
|
||||
case 80:
|
||||
return QStringLiteral("小阵雨");
|
||||
case 81:
|
||||
return QStringLiteral("中阵雨");
|
||||
case 82:
|
||||
return QStringLiteral("强阵雨");
|
||||
case 85:
|
||||
case 86:
|
||||
return QStringLiteral("阵雪");
|
||||
case 95:
|
||||
return QStringLiteral("雷暴");
|
||||
case 96:
|
||||
case 99:
|
||||
return QStringLiteral("雷暴伴冰雹");
|
||||
default:
|
||||
return QStringLiteral("未知天气");
|
||||
}
|
||||
}
|
||||
|
||||
QString WeatherSummaryFormatter::windDirectionText(double degree)
|
||||
{
|
||||
const QStringList directions = {
|
||||
QStringLiteral("北风"),
|
||||
QStringLiteral("东北风"),
|
||||
QStringLiteral("东风"),
|
||||
QStringLiteral("东南风"),
|
||||
QStringLiteral("南风"),
|
||||
QStringLiteral("西南风"),
|
||||
QStringLiteral("西风"),
|
||||
QStringLiteral("西北风"),
|
||||
};
|
||||
const int index = static_cast<int>(qRound(degree / 45.0)) % directions.size();
|
||||
return directions.at(index);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "WeatherTypes.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
class WeatherSummaryFormatter
|
||||
{
|
||||
public:
|
||||
QString format(const WeatherReport &report) const;
|
||||
|
||||
static QString weatherCodeText(int code);
|
||||
static QString windDirectionText(double degree);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include "WeatherConfig.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
enum class WeatherLocationSource
|
||||
{
|
||||
ExplicitCity,
|
||||
SettingsDefault,
|
||||
IpFallback,
|
||||
};
|
||||
|
||||
enum class WeatherQueryKind
|
||||
{
|
||||
Current,
|
||||
Daily,
|
||||
Range,
|
||||
};
|
||||
|
||||
struct WeatherQuery
|
||||
{
|
||||
QString originalText;
|
||||
QString cityName;
|
||||
WeatherLocationSource locationSource = WeatherLocationSource::ExplicitCity;
|
||||
WeatherQueryKind kind = WeatherQueryKind::Current;
|
||||
int dateOffset = 0;
|
||||
int forecastDays = 1;
|
||||
QString unsupportedReason;
|
||||
};
|
||||
|
||||
struct WeatherLocation
|
||||
{
|
||||
QString cityName;
|
||||
QString adminName;
|
||||
QString countryName;
|
||||
QString timezone;
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
WeatherLocationSource source = WeatherLocationSource::ExplicitCity;
|
||||
};
|
||||
|
||||
struct WeatherLocationCandidate
|
||||
{
|
||||
QString cityName;
|
||||
QString adminName;
|
||||
QString countryName;
|
||||
QString timezone;
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
};
|
||||
|
||||
struct WeatherCurrent
|
||||
{
|
||||
bool valid = false;
|
||||
QDateTime time;
|
||||
int weatherCode = -1;
|
||||
double temperatureC = 0.0;
|
||||
bool hasTemperature = false;
|
||||
double apparentTemperatureC = 0.0;
|
||||
bool hasApparentTemperature = false;
|
||||
double humidityPercent = 0.0;
|
||||
bool hasHumidity = false;
|
||||
double windSpeedKmh = 0.0;
|
||||
bool hasWindSpeed = false;
|
||||
double windDirectionDegree = 0.0;
|
||||
bool hasWindDirection = false;
|
||||
double precipitationMm = 0.0;
|
||||
bool hasPrecipitation = false;
|
||||
};
|
||||
|
||||
struct WeatherDailyForecast
|
||||
{
|
||||
QDate date;
|
||||
int weatherCode = -1;
|
||||
double temperatureMaxC = 0.0;
|
||||
bool hasTemperatureMax = false;
|
||||
double temperatureMinC = 0.0;
|
||||
bool hasTemperatureMin = false;
|
||||
double precipitationProbabilityPercent = 0.0;
|
||||
bool hasPrecipitationProbability = false;
|
||||
};
|
||||
|
||||
struct WeatherReport
|
||||
{
|
||||
WeatherQuery query;
|
||||
WeatherLocation location;
|
||||
WeatherCurrent current;
|
||||
QVector<WeatherDailyForecast> dailyForecasts;
|
||||
QVector<WeatherLocationCandidate> locationCandidates;
|
||||
QString dataSourceName = QStringLiteral("Open-Meteo");
|
||||
bool hasLocationAmbiguity = false;
|
||||
};
|
||||
|
||||
struct WeatherQueryResult
|
||||
{
|
||||
bool success = false;
|
||||
QString message;
|
||||
QString errorMessage;
|
||||
WeatherReport report;
|
||||
};
|
||||
|
||||
struct LocationTestResult
|
||||
{
|
||||
bool success = false;
|
||||
QString errorMessage;
|
||||
WeatherLocation selectedLocation;
|
||||
QVector<WeatherLocationCandidate> candidates;
|
||||
bool hasAmbiguity = false;
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "WebCapabilityDetector.h"
|
||||
|
||||
#include <QUrl>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString normalizedProviderName(const QString &provider)
|
||||
{
|
||||
const QString normalized = provider.trimmed().toLower();
|
||||
return normalized.isEmpty() ? QStringLiteral("custom") : normalized;
|
||||
}
|
||||
|
||||
QString normalizedMode(const QString &mode)
|
||||
{
|
||||
const QString normalized = mode.trimmed().toLower();
|
||||
if (normalized == QStringLiteral("openai") || normalized == QStringLiteral("gemini"))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
return QStringLiteral("auto");
|
||||
}
|
||||
|
||||
bool isOfficialOpenAIBaseUrl(const QString &baseUrl)
|
||||
{
|
||||
const QUrl url(baseUrl.trimmed());
|
||||
return url.isValid()
|
||||
&& url.scheme().startsWith(QStringLiteral("http"))
|
||||
&& url.host().compare(QStringLiteral("api.openai.com"), Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
bool isGeminiProtocol(const AIConfig &config)
|
||||
{
|
||||
return config.protocol == QStringLiteral("google-generative-language");
|
||||
}
|
||||
|
||||
bool hasApiKeyReference(const AIConfig &config)
|
||||
{
|
||||
return !config.apiKey.trimmed().isEmpty()
|
||||
|| !config.apiKeyEncrypted.trimmed().isEmpty();
|
||||
}
|
||||
|
||||
WebCapability missingAIConfigCapability()
|
||||
{
|
||||
return {
|
||||
false,
|
||||
WebProviderKind::None,
|
||||
QStringLiteral("AI 未配置完整"),
|
||||
QStringLiteral("请先在设置页配置当前 AI Provider 的 Base URL、Model 和 API Key。")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
namespace WebCapabilityDetector
|
||||
{
|
||||
QString providerKindName(WebProviderKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case WebProviderKind::OpenAIResponses:
|
||||
return QStringLiteral("OpenAI Web Search");
|
||||
case WebProviderKind::GeminiGrounding:
|
||||
return QStringLiteral("Gemini Google Search");
|
||||
case WebProviderKind::None:
|
||||
return QStringLiteral("None");
|
||||
}
|
||||
|
||||
return QStringLiteral("None");
|
||||
}
|
||||
|
||||
WebCapability detect(const AIConfig &config, const WebConfig &webConfig)
|
||||
{
|
||||
if (!webConfig.enabled)
|
||||
{
|
||||
return {
|
||||
false,
|
||||
WebProviderKind::None,
|
||||
QStringLiteral("联网模式已关闭"),
|
||||
QStringLiteral("联网模式已在设置页关闭。")
|
||||
};
|
||||
}
|
||||
|
||||
const QString provider = normalizedProviderName(config.provider);
|
||||
const QString mode = normalizedMode(webConfig.providerMode);
|
||||
|
||||
if ((mode == QStringLiteral("auto") || mode == QStringLiteral("openai"))
|
||||
&& provider == QStringLiteral("openai")
|
||||
&& config.protocol == QStringLiteral("openai-compatible"))
|
||||
{
|
||||
if (config.baseUrl.trimmed().isEmpty() || config.model.trimmed().isEmpty() || !hasApiKeyReference(config))
|
||||
{
|
||||
return missingAIConfigCapability();
|
||||
}
|
||||
|
||||
if (!isOfficialOpenAIBaseUrl(config.baseUrl))
|
||||
{
|
||||
return {
|
||||
false,
|
||||
WebProviderKind::None,
|
||||
QStringLiteral("OpenAI 联网仅支持官方 API"),
|
||||
QStringLiteral("OpenAI 联网模式仅在 Base URL 为 https://api.openai.com 时可用。")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
true,
|
||||
WebProviderKind::OpenAIResponses,
|
||||
QStringLiteral("OpenAI 官方联网能力可用"),
|
||||
QStringLiteral("当前 OpenAI 官方配置支持联网模式。")
|
||||
};
|
||||
}
|
||||
|
||||
if ((mode == QStringLiteral("auto") || mode == QStringLiteral("gemini"))
|
||||
&& isGeminiProtocol(config))
|
||||
{
|
||||
if (config.baseUrl.trimmed().isEmpty() || config.model.trimmed().isEmpty() || !hasApiKeyReference(config))
|
||||
{
|
||||
return missingAIConfigCapability();
|
||||
}
|
||||
|
||||
return {
|
||||
true,
|
||||
WebProviderKind::GeminiGrounding,
|
||||
QStringLiteral("Gemini 官方联网能力可用"),
|
||||
QStringLiteral("当前 Gemini 配置支持联网模式。")
|
||||
};
|
||||
}
|
||||
|
||||
if (provider == QStringLiteral("deepseek"))
|
||||
{
|
||||
return {
|
||||
false,
|
||||
WebProviderKind::None,
|
||||
QStringLiteral("DeepSeek 不支持原生联网"),
|
||||
QStringLiteral("当前 DeepSeek 官方 API 暂不提供原生联网模式。")
|
||||
};
|
||||
}
|
||||
|
||||
if (provider == QStringLiteral("custom"))
|
||||
{
|
||||
return {
|
||||
false,
|
||||
WebProviderKind::None,
|
||||
QStringLiteral("自定义 Provider 无法确认联网能力"),
|
||||
QStringLiteral("当前自定义或第三方兼容 API 无法确认支持联网模式。")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
false,
|
||||
WebProviderKind::None,
|
||||
QStringLiteral("当前 AI 配置不支持联网模式"),
|
||||
QStringLiteral("当前 AI 配置没有可用的原生联网功能。")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "../config/AIConfig.h"
|
||||
#include "WebChatTypes.h"
|
||||
#include "WebConfig.h"
|
||||
|
||||
namespace WebCapabilityDetector
|
||||
{
|
||||
WebCapability detect(const AIConfig &config, const WebConfig &webConfig = WebConfig());
|
||||
QString providerKindName(WebProviderKind kind);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
#include "WebChatManager.h"
|
||||
|
||||
#include "../ai/AIDiagnostics.h"
|
||||
#include "../ai/AIProviderFactory.h"
|
||||
#include "../util/Logger.h"
|
||||
#include "WebCapabilityDetector.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString normalizedGeminiModel(QString model)
|
||||
{
|
||||
model = model.trimmed();
|
||||
if (model.startsWith(QStringLiteral("models/")))
|
||||
{
|
||||
model.remove(0, QStringLiteral("models/").size());
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
QString geminiRole(const QString &role)
|
||||
{
|
||||
if (role == QStringLiteral("assistant") || role == QStringLiteral("model"))
|
||||
{
|
||||
return QStringLiteral("model");
|
||||
}
|
||||
return QStringLiteral("user");
|
||||
}
|
||||
|
||||
QJsonObject textPart(const QString &text)
|
||||
{
|
||||
QJsonObject part;
|
||||
part.insert(QStringLiteral("text"), text);
|
||||
return part;
|
||||
}
|
||||
|
||||
QString openAIResponsesUrl(const AIConfig &config)
|
||||
{
|
||||
QString baseUrl = config.baseUrl.trimmed();
|
||||
while (baseUrl.endsWith(QLatin1Char('/')))
|
||||
{
|
||||
baseUrl.chop(1);
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith(QStringLiteral("/v1")))
|
||||
{
|
||||
return baseUrl + QStringLiteral("/responses");
|
||||
}
|
||||
|
||||
return baseUrl + QStringLiteral("/v1/responses");
|
||||
}
|
||||
|
||||
QUrl geminiRequestUrl(const AIConfig &config)
|
||||
{
|
||||
QString baseUrl = config.baseUrl.trimmed();
|
||||
QString path = config.path.trimmed();
|
||||
if (path.isEmpty())
|
||||
{
|
||||
path = QStringLiteral("/v1beta/models/{model}:generateContent");
|
||||
}
|
||||
|
||||
while (baseUrl.endsWith(QLatin1Char('/')))
|
||||
{
|
||||
baseUrl.chop(1);
|
||||
}
|
||||
|
||||
if (!path.startsWith(QLatin1Char('/')))
|
||||
{
|
||||
path.prepend(QLatin1Char('/'));
|
||||
}
|
||||
|
||||
if (path.contains(QStringLiteral(":streamGenerateContent")))
|
||||
{
|
||||
path.replace(QStringLiteral(":streamGenerateContent"), QStringLiteral(":generateContent"));
|
||||
}
|
||||
|
||||
const QString model = QString::fromUtf8(QUrl::toPercentEncoding(normalizedGeminiModel(config.model)));
|
||||
path.replace(QStringLiteral("{model}"), model);
|
||||
return QUrl(baseUrl + path);
|
||||
}
|
||||
|
||||
QJsonObject openAIInputMessage(const ChatMessage &message)
|
||||
{
|
||||
QJsonObject item;
|
||||
item.insert(QStringLiteral("role"), message.role == QStringLiteral("assistant") ? QStringLiteral("assistant") : QStringLiteral("user"));
|
||||
item.insert(QStringLiteral("content"), message.content);
|
||||
return item;
|
||||
}
|
||||
|
||||
QJsonObject openAIPayload(const ChatRequest &request, const AIConfig &config)
|
||||
{
|
||||
QStringList instructions;
|
||||
QJsonArray input;
|
||||
for (const ChatMessage &message : request.messages)
|
||||
{
|
||||
const QString content = message.content.trimmed();
|
||||
if (content.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role == QStringLiteral("system"))
|
||||
{
|
||||
instructions.append(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
input.append(openAIInputMessage(message));
|
||||
}
|
||||
|
||||
QJsonArray tools;
|
||||
QJsonObject webSearchTool;
|
||||
webSearchTool.insert(QStringLiteral("type"), QStringLiteral("web_search"));
|
||||
tools.append(webSearchTool);
|
||||
|
||||
QJsonArray include;
|
||||
include.append(QStringLiteral("web_search_call.action.sources"));
|
||||
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("model"), config.model);
|
||||
payload.insert(QStringLiteral("input"), input);
|
||||
payload.insert(QStringLiteral("tools"), tools);
|
||||
payload.insert(QStringLiteral("tool_choice"), QStringLiteral("auto"));
|
||||
payload.insert(QStringLiteral("include"), include);
|
||||
payload.insert(QStringLiteral("temperature"), config.temperature);
|
||||
payload.insert(QStringLiteral("max_output_tokens"), config.maxTokens);
|
||||
if (!instructions.isEmpty())
|
||||
{
|
||||
payload.insert(QStringLiteral("instructions"), instructions.join(QStringLiteral("\n\n")));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
QJsonObject geminiPayload(const ChatRequest &request, const AIConfig &config)
|
||||
{
|
||||
QStringList systemParts;
|
||||
QJsonArray contents;
|
||||
|
||||
for (const ChatMessage &message : request.messages)
|
||||
{
|
||||
const QString content = message.content.trimmed();
|
||||
if (content.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role == QStringLiteral("system"))
|
||||
{
|
||||
systemParts.append(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
QJsonArray parts;
|
||||
parts.append(textPart(content));
|
||||
|
||||
QJsonObject item;
|
||||
item.insert(QStringLiteral("role"), geminiRole(message.role));
|
||||
item.insert(QStringLiteral("parts"), parts);
|
||||
contents.append(item);
|
||||
}
|
||||
|
||||
QJsonArray tools;
|
||||
QJsonObject googleSearchTool;
|
||||
googleSearchTool.insert(QStringLiteral("google_search"), QJsonObject());
|
||||
tools.append(googleSearchTool);
|
||||
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("contents"), contents);
|
||||
payload.insert(QStringLiteral("tools"), tools);
|
||||
|
||||
if (!systemParts.isEmpty())
|
||||
{
|
||||
QJsonArray parts;
|
||||
parts.append(textPart(systemParts.join(QStringLiteral("\n\n"))));
|
||||
|
||||
QJsonObject systemInstruction;
|
||||
systemInstruction.insert(QStringLiteral("parts"), parts);
|
||||
payload.insert(QStringLiteral("systemInstruction"), systemInstruction);
|
||||
}
|
||||
|
||||
QJsonObject generationConfig;
|
||||
generationConfig.insert(QStringLiteral("temperature"), config.temperature);
|
||||
generationConfig.insert(QStringLiteral("maxOutputTokens"), config.maxTokens);
|
||||
payload.insert(QStringLiteral("generationConfig"), generationConfig);
|
||||
return payload;
|
||||
}
|
||||
|
||||
QString textFromGeminiCandidate(const QJsonObject &candidate)
|
||||
{
|
||||
const QJsonObject content = candidate.value(QStringLiteral("content")).toObject();
|
||||
const QJsonArray parts = content.value(QStringLiteral("parts")).toArray();
|
||||
|
||||
QString result;
|
||||
for (const QJsonValue &partValue : parts)
|
||||
{
|
||||
if (!partValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString text = partValue.toObject().value(QStringLiteral("text")).toString();
|
||||
if (!text.isEmpty())
|
||||
{
|
||||
result += text;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void appendCitation(QVector<WebCitation> *citations, QSet<QString> *seenUrls, QString title, QString url, const QString &sourceName)
|
||||
{
|
||||
if (citations == nullptr || seenUrls == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
title = title.trimmed();
|
||||
url = url.trimmed();
|
||||
if (url.isEmpty() || seenUrls->contains(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WebCitation citation;
|
||||
citation.index = citations->size() + 1;
|
||||
citation.title = title.isEmpty() ? url : title;
|
||||
citation.url = url;
|
||||
citation.sourceName = sourceName;
|
||||
citations->append(citation);
|
||||
seenUrls->insert(url);
|
||||
}
|
||||
|
||||
void collectOpenAIAnnotations(
|
||||
const QJsonArray &annotations,
|
||||
QVector<WebCitation> *citations,
|
||||
QSet<QString> *seenUrls)
|
||||
{
|
||||
for (const QJsonValue &annotationValue : annotations)
|
||||
{
|
||||
if (!annotationValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject annotation = annotationValue.toObject();
|
||||
const QString type = annotation.value(QStringLiteral("type")).toString();
|
||||
if (type != QStringLiteral("url_citation"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
appendCitation(
|
||||
citations,
|
||||
seenUrls,
|
||||
annotation.value(QStringLiteral("title")).toString(),
|
||||
annotation.value(QStringLiteral("url")).toString(),
|
||||
QStringLiteral("OpenAI Web Search"));
|
||||
}
|
||||
}
|
||||
|
||||
void collectOpenAISourceObjects(
|
||||
const QJsonValue &value,
|
||||
QVector<WebCitation> *citations,
|
||||
QSet<QString> *seenUrls)
|
||||
{
|
||||
if (value.isArray())
|
||||
{
|
||||
const QJsonArray array = value.toArray();
|
||||
for (const QJsonValue &item : array)
|
||||
{
|
||||
collectOpenAISourceObjects(item, citations, seenUrls);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value.isObject())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject object = value.toObject();
|
||||
const auto appendSourceArray = [&object, citations, seenUrls](const QString &fieldName) {
|
||||
const QJsonArray sources = object.value(fieldName).toArray();
|
||||
for (const QJsonValue &sourceValue : sources)
|
||||
{
|
||||
if (!sourceValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject source = sourceValue.toObject();
|
||||
QString url = source.value(QStringLiteral("url")).toString().trimmed();
|
||||
if (url.isEmpty())
|
||||
{
|
||||
url = source.value(QStringLiteral("uri")).toString().trimmed();
|
||||
}
|
||||
|
||||
appendCitation(
|
||||
citations,
|
||||
seenUrls,
|
||||
source.value(QStringLiteral("title")).toString(),
|
||||
url,
|
||||
QStringLiteral("OpenAI Web Search"));
|
||||
}
|
||||
};
|
||||
|
||||
appendSourceArray(QStringLiteral("sources"));
|
||||
appendSourceArray(QStringLiteral("results"));
|
||||
|
||||
for (auto iterator = object.constBegin(); iterator != object.constEnd(); ++iterator)
|
||||
{
|
||||
collectOpenAISourceObjects(iterator.value(), citations, seenUrls);
|
||||
}
|
||||
}
|
||||
|
||||
void collectGeminiGroundingCitations(
|
||||
const QJsonObject &candidate,
|
||||
QVector<WebCitation> *citations,
|
||||
QSet<QString> *seenUrls)
|
||||
{
|
||||
const QJsonObject grounding = candidate.value(QStringLiteral("groundingMetadata")).toObject();
|
||||
const QJsonArray chunks = grounding.value(QStringLiteral("groundingChunks")).toArray();
|
||||
for (const QJsonValue &chunkValue : chunks)
|
||||
{
|
||||
if (!chunkValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject web = chunkValue.toObject().value(QStringLiteral("web")).toObject();
|
||||
appendCitation(
|
||||
citations,
|
||||
seenUrls,
|
||||
web.value(QStringLiteral("title")).toString(),
|
||||
web.value(QStringLiteral("uri")).toString(),
|
||||
QStringLiteral("Google Search"));
|
||||
}
|
||||
|
||||
const QJsonObject citationMetadata = candidate.value(QStringLiteral("citationMetadata")).toObject();
|
||||
const QJsonArray citationSources = citationMetadata.value(QStringLiteral("citationSources")).toArray();
|
||||
for (const QJsonValue &sourceValue : citationSources)
|
||||
{
|
||||
if (!sourceValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject source = sourceValue.toObject();
|
||||
appendCitation(
|
||||
citations,
|
||||
seenUrls,
|
||||
source.value(QStringLiteral("title")).toString(),
|
||||
source.value(QStringLiteral("uri")).toString(),
|
||||
QStringLiteral("Google Search"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WebChatManager::WebChatManager()
|
||||
{
|
||||
m_timeoutTimer.setSingleShot(true);
|
||||
QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() {
|
||||
finishWithError(QStringLiteral("联网请求超时。"));
|
||||
});
|
||||
}
|
||||
|
||||
WebChatManager::~WebChatManager()
|
||||
{
|
||||
cancel();
|
||||
}
|
||||
|
||||
bool WebChatManager::isBusy() const
|
||||
{
|
||||
return !m_currentReply.isNull();
|
||||
}
|
||||
|
||||
void WebChatManager::sendWebChat(const WebChatRequest &request, WebChatCallback callback)
|
||||
{
|
||||
if (isBusy())
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
callback({false, false, {}, QStringLiteral("联网请求正在进行,请稍后。"), {}, 0});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
m_callback = std::move(callback);
|
||||
AIConfig runtimeConfig = request.aiConfig;
|
||||
WebConfig webConfig = request.webConfig;
|
||||
const WebCapability capability = WebCapabilityDetector::detect(runtimeConfig, webConfig);
|
||||
if (!capability.supported)
|
||||
{
|
||||
invokeCallback({false, false, {}, capability.userMessage, {}, 0});
|
||||
return;
|
||||
}
|
||||
|
||||
QString errorMessage;
|
||||
if (!AIProviderFactory::prepareRuntimeConfig(runtimeConfig, &errorMessage))
|
||||
{
|
||||
invokeCallback({false, false, {}, errorMessage, {}, 0});
|
||||
return;
|
||||
}
|
||||
runtimeConfig.timeoutMs = qBound(3000, webConfig.timeoutMs, 120000);
|
||||
|
||||
if (request.chatRequest.messages.isEmpty())
|
||||
{
|
||||
invokeCallback({false, false, {}, QStringLiteral("联网请求内容为空。"), {}, 0});
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentConfig = runtimeConfig;
|
||||
m_currentKind = capability.kind;
|
||||
|
||||
if (capability.kind == WebProviderKind::OpenAIResponses)
|
||||
{
|
||||
startOpenAIResponses({request.chatRequest, runtimeConfig, webConfig});
|
||||
return;
|
||||
}
|
||||
|
||||
if (capability.kind == WebProviderKind::GeminiGrounding)
|
||||
{
|
||||
startGeminiGrounding({request.chatRequest, runtimeConfig, webConfig});
|
||||
return;
|
||||
}
|
||||
|
||||
invokeCallback({false, false, {}, QStringLiteral("当前 AI 配置没有可用的原生联网功能。"), {}, 0});
|
||||
}
|
||||
|
||||
void WebChatManager::cancel()
|
||||
{
|
||||
m_callback = nullptr;
|
||||
QPointer<QNetworkReply> reply = m_currentReply;
|
||||
if (!reply.isNull())
|
||||
{
|
||||
Logger::info(QStringLiteral("Web chat request canceled: %1")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url())));
|
||||
}
|
||||
clearReply();
|
||||
|
||||
if (!reply.isNull())
|
||||
{
|
||||
reply->abort();
|
||||
}
|
||||
}
|
||||
|
||||
void WebChatManager::startOpenAIResponses(const WebChatRequest &request)
|
||||
{
|
||||
const QUrl url(openAIResponsesUrl(request.aiConfig));
|
||||
const QByteArray payload = QJsonDocument(openAIPayload(request.chatRequest, request.aiConfig)).toJson(QJsonDocument::Compact);
|
||||
startRequest(url, payload, request.chatRequest.messages.size(), QByteArray("Bearer ") + request.aiConfig.apiKey.toUtf8());
|
||||
}
|
||||
|
||||
void WebChatManager::startGeminiGrounding(const WebChatRequest &request)
|
||||
{
|
||||
const QUrl url = geminiRequestUrl(request.aiConfig);
|
||||
const QByteArray payload = QJsonDocument(geminiPayload(request.chatRequest, request.aiConfig)).toJson(QJsonDocument::Compact);
|
||||
startRequest(url, payload, request.chatRequest.messages.size());
|
||||
}
|
||||
|
||||
void WebChatManager::startRequest(const QUrl &url, const QByteArray &payload, int messageCount, const QByteArray &authorizationHeader)
|
||||
{
|
||||
if (!url.isValid())
|
||||
{
|
||||
invokeCallback({false, false, {}, QStringLiteral("联网请求 URL 无效。"), {}, 0});
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest networkRequest(url);
|
||||
networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
if (!authorizationHeader.isEmpty())
|
||||
{
|
||||
networkRequest.setRawHeader("Authorization", authorizationHeader);
|
||||
}
|
||||
if (m_currentKind == WebProviderKind::GeminiGrounding)
|
||||
{
|
||||
networkRequest.setRawHeader("x-goog-api-key", m_currentConfig.apiKey.toUtf8());
|
||||
}
|
||||
|
||||
Logger::info(QStringLiteral("Web chat request started: %1 kind=%2 messageCount=%3 payloadBytes=%4")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, url))
|
||||
.arg(WebCapabilityDetector::providerKindName(m_currentKind))
|
||||
.arg(QString::number(messageCount))
|
||||
.arg(QString::number(payload.size())));
|
||||
|
||||
m_currentReply = m_networkManager.post(networkRequest, payload);
|
||||
m_replyFinishedConnection = QObject::connect(m_currentReply, &QNetworkReply::finished, [this]() {
|
||||
finishReply();
|
||||
});
|
||||
m_timeoutTimer.start(qBound(3000, m_currentConfig.timeoutMs, 120000));
|
||||
}
|
||||
|
||||
void WebChatManager::finishReply()
|
||||
{
|
||||
if (m_currentReply.isNull())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkReply *reply = m_currentReply;
|
||||
const QByteArray body = reply->readAll();
|
||||
WebChatResponse response;
|
||||
if (m_currentKind == WebProviderKind::OpenAIResponses)
|
||||
{
|
||||
response = parseOpenAIResponse(reply, body);
|
||||
}
|
||||
else if (m_currentKind == WebProviderKind::GeminiGrounding)
|
||||
{
|
||||
response = parseGeminiResponse(reply, body);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = {false, false, {}, QStringLiteral("未知联网 Provider。"), {}, 0};
|
||||
}
|
||||
|
||||
clearReply();
|
||||
invokeCallback(response);
|
||||
}
|
||||
|
||||
void WebChatManager::finishWithError(const QString &message, int httpStatus)
|
||||
{
|
||||
QPointer<QNetworkReply> reply = m_currentReply;
|
||||
const QUrl url = reply.isNull() ? QUrl() : reply->request().url();
|
||||
Logger::warning(QStringLiteral("Web chat request finished with error: %1 httpStatus=%2 error=\"%3\"")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, url))
|
||||
.arg(httpStatus)
|
||||
.arg(AIDiagnostics::safeTextSummary(message)));
|
||||
|
||||
clearReply();
|
||||
if (!reply.isNull())
|
||||
{
|
||||
reply->abort();
|
||||
}
|
||||
|
||||
invokeCallback({false, false, {}, message, {}, httpStatus});
|
||||
}
|
||||
|
||||
void WebChatManager::invokeCallback(const WebChatResponse &response)
|
||||
{
|
||||
if (!m_callback)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const WebChatCallback callback = std::move(m_callback);
|
||||
m_callback = nullptr;
|
||||
callback(response);
|
||||
}
|
||||
|
||||
void WebChatManager::clearReply()
|
||||
{
|
||||
m_timeoutTimer.stop();
|
||||
if (m_replyFinishedConnection)
|
||||
{
|
||||
QObject::disconnect(m_replyFinishedConnection);
|
||||
m_replyFinishedConnection = {};
|
||||
}
|
||||
|
||||
if (!m_currentReply.isNull())
|
||||
{
|
||||
m_currentReply->deleteLater();
|
||||
m_currentReply.clear();
|
||||
}
|
||||
m_currentKind = WebProviderKind::None;
|
||||
}
|
||||
|
||||
WebChatResponse WebChatManager::parseOpenAIResponse(QNetworkReply *reply, const QByteArray &body) const
|
||||
{
|
||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
|
||||
Logger::warning(QStringLiteral("Web chat OpenAI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))
|
||||
.arg(static_cast<int>(reply->error()))
|
||||
.arg(AIDiagnostics::oneLine(reply->errorString()))
|
||||
.arg(httpStatus)
|
||||
.arg(AIDiagnostics::responseBodySummary(body)));
|
||||
return {false, false, {}, bodyError.isEmpty() ? reply->errorString() : bodyError, {}, httpStatus};
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Web chat OpenAI response JSON parse failed: %1 parseError=\"%2\" bodySummary=\"%3\"")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))
|
||||
.arg(AIDiagnostics::oneLine(parseError.errorString()))
|
||||
.arg(AIDiagnostics::responseBodySummary(body)));
|
||||
return {false, false, {}, QStringLiteral("联网响应不是有效 JSON。"), {}, httpStatus};
|
||||
}
|
||||
|
||||
const QJsonObject root = document.object();
|
||||
if (root.contains(QStringLiteral("error")))
|
||||
{
|
||||
const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
|
||||
return {false, false, {}, bodyError.isEmpty() ? QStringLiteral("联网响应返回错误。") : bodyError, {}, httpStatus};
|
||||
}
|
||||
|
||||
QString content = root.value(QStringLiteral("output_text")).toString();
|
||||
QVector<WebCitation> citations;
|
||||
QSet<QString> seenUrls;
|
||||
bool usedWeb = false;
|
||||
|
||||
const QJsonArray output = root.value(QStringLiteral("output")).toArray();
|
||||
for (const QJsonValue &outputValue : output)
|
||||
{
|
||||
if (!outputValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject outputItem = outputValue.toObject();
|
||||
const QString type = outputItem.value(QStringLiteral("type")).toString();
|
||||
if (type.contains(QStringLiteral("web_search"), Qt::CaseInsensitive))
|
||||
{
|
||||
usedWeb = true;
|
||||
collectOpenAISourceObjects(outputItem, &citations, &seenUrls);
|
||||
}
|
||||
|
||||
const QJsonArray contentItems = outputItem.value(QStringLiteral("content")).toArray();
|
||||
for (const QJsonValue &contentValue : contentItems)
|
||||
{
|
||||
if (!contentValue.isObject())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject contentItem = contentValue.toObject();
|
||||
const QString text = contentItem.value(QStringLiteral("text")).toString();
|
||||
if (content.trimmed().isEmpty() && !text.isEmpty())
|
||||
{
|
||||
content += text;
|
||||
}
|
||||
collectOpenAIAnnotations(contentItem.value(QStringLiteral("annotations")).toArray(), &citations, &seenUrls);
|
||||
}
|
||||
}
|
||||
|
||||
usedWeb = usedWeb || !citations.isEmpty();
|
||||
if (content.trimmed().isEmpty())
|
||||
{
|
||||
return {false, usedWeb, {}, QStringLiteral("联网响应内容为空。"), citations, httpStatus};
|
||||
}
|
||||
|
||||
Logger::info(QStringLiteral("Web chat OpenAI request completed: %1 httpStatus=%2 responseChars=%3 citations=%4 usedWeb=%5")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))
|
||||
.arg(httpStatus)
|
||||
.arg(QString::number(content.size()))
|
||||
.arg(QString::number(citations.size()))
|
||||
.arg(usedWeb ? QStringLiteral("true") : QStringLiteral("false")));
|
||||
|
||||
return {true, usedWeb, content, {}, citations, httpStatus};
|
||||
}
|
||||
|
||||
WebChatResponse WebChatManager::parseGeminiResponse(QNetworkReply *reply, const QByteArray &body) const
|
||||
{
|
||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
|
||||
Logger::warning(QStringLiteral("Web chat Gemini request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))
|
||||
.arg(static_cast<int>(reply->error()))
|
||||
.arg(AIDiagnostics::oneLine(reply->errorString()))
|
||||
.arg(httpStatus)
|
||||
.arg(AIDiagnostics::responseBodySummary(body)));
|
||||
return {false, false, {}, bodyError.isEmpty() ? reply->errorString() : bodyError, {}, httpStatus};
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Web chat Gemini response JSON parse failed: %1 parseError=\"%2\" bodySummary=\"%3\"")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))
|
||||
.arg(AIDiagnostics::oneLine(parseError.errorString()))
|
||||
.arg(AIDiagnostics::responseBodySummary(body)));
|
||||
return {false, false, {}, QStringLiteral("联网响应不是有效 JSON。"), {}, httpStatus};
|
||||
}
|
||||
|
||||
const QJsonObject root = document.object();
|
||||
if (root.contains(QStringLiteral("error")))
|
||||
{
|
||||
const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
|
||||
return {false, false, {}, bodyError.isEmpty() ? QStringLiteral("联网响应返回错误。") : bodyError, {}, httpStatus};
|
||||
}
|
||||
|
||||
const QJsonArray candidates = root.value(QStringLiteral("candidates")).toArray();
|
||||
if (candidates.isEmpty() || !candidates.first().isObject())
|
||||
{
|
||||
return {false, false, {}, QStringLiteral("Gemini 联网响应没有候选结果。"), {}, httpStatus};
|
||||
}
|
||||
|
||||
const QJsonObject candidate = candidates.first().toObject();
|
||||
const QString content = textFromGeminiCandidate(candidate);
|
||||
QVector<WebCitation> citations;
|
||||
QSet<QString> seenUrls;
|
||||
collectGeminiGroundingCitations(candidate, &citations, &seenUrls);
|
||||
|
||||
const bool usedWeb = candidate.contains(QStringLiteral("groundingMetadata")) || !citations.isEmpty();
|
||||
if (content.trimmed().isEmpty())
|
||||
{
|
||||
return {false, usedWeb, {}, QStringLiteral("Gemini 联网响应内容为空。"), citations, httpStatus};
|
||||
}
|
||||
|
||||
Logger::info(QStringLiteral("Web chat Gemini request completed: %1 httpStatus=%2 responseChars=%3 citations=%4 usedWeb=%5")
|
||||
.arg(AIDiagnostics::diagnosticContext(m_currentConfig, reply->request().url()))
|
||||
.arg(httpStatus)
|
||||
.arg(QString::number(content.size()))
|
||||
.arg(QString::number(citations.size()))
|
||||
.arg(usedWeb ? QStringLiteral("true") : QStringLiteral("false")));
|
||||
|
||||
return {true, usedWeb, content, {}, citations, httpStatus};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include "WebChatTypes.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QMetaObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QPointer>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QNetworkReply;
|
||||
|
||||
class WebChatManager
|
||||
{
|
||||
public:
|
||||
using WebChatCallback = std::function<void(const WebChatResponse &)>;
|
||||
|
||||
WebChatManager();
|
||||
~WebChatManager();
|
||||
|
||||
bool isBusy() const;
|
||||
void sendWebChat(const WebChatRequest &request, WebChatCallback callback);
|
||||
void cancel();
|
||||
|
||||
private:
|
||||
void startOpenAIResponses(const WebChatRequest &request);
|
||||
void startGeminiGrounding(const WebChatRequest &request);
|
||||
void startRequest(const QUrl &url, const QByteArray &payload, int messageCount, const QByteArray &authorizationHeader = {});
|
||||
void finishReply();
|
||||
void finishWithError(const QString &message, int httpStatus = 0);
|
||||
void invokeCallback(const WebChatResponse &response);
|
||||
void clearReply();
|
||||
|
||||
WebChatResponse parseOpenAIResponse(QNetworkReply *reply, const QByteArray &body) const;
|
||||
WebChatResponse parseGeminiResponse(QNetworkReply *reply, const QByteArray &body) const;
|
||||
|
||||
QNetworkAccessManager m_networkManager;
|
||||
QPointer<QNetworkReply> m_currentReply;
|
||||
QMetaObject::Connection m_replyFinishedConnection;
|
||||
QTimer m_timeoutTimer;
|
||||
WebChatCallback m_callback;
|
||||
WebProviderKind m_currentKind = WebProviderKind::None;
|
||||
AIConfig m_currentConfig;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include "../ai/LLMTypes.h"
|
||||
#include "../config/AIConfig.h"
|
||||
#include "WebConfig.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
enum class WebProviderKind
|
||||
{
|
||||
None,
|
||||
OpenAIResponses,
|
||||
GeminiGrounding,
|
||||
};
|
||||
|
||||
struct WebCapability
|
||||
{
|
||||
bool supported = false;
|
||||
WebProviderKind kind = WebProviderKind::None;
|
||||
QString statusText;
|
||||
QString userMessage;
|
||||
};
|
||||
|
||||
struct WebCitation
|
||||
{
|
||||
int index = 0;
|
||||
QString title;
|
||||
QString url;
|
||||
QString sourceName;
|
||||
};
|
||||
|
||||
struct WebChatRequest
|
||||
{
|
||||
ChatRequest chatRequest;
|
||||
AIConfig aiConfig;
|
||||
WebConfig webConfig;
|
||||
};
|
||||
|
||||
struct WebChatResponse
|
||||
{
|
||||
bool success = false;
|
||||
bool usedWeb = false;
|
||||
QString content;
|
||||
QString errorMessage;
|
||||
QVector<WebCitation> citations;
|
||||
int httpStatus = 0;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct WebConfig
|
||||
{
|
||||
bool enabled = true;
|
||||
bool rememberLastToggle = true;
|
||||
bool defaultToggleOn = false;
|
||||
bool lastToggleOn = false;
|
||||
QString providerMode = QStringLiteral("auto");
|
||||
int timeoutMs = 60000;
|
||||
bool showCitations = true;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user