Finalize desktop pet feature set and GitHub export
This commit is contained in:
@@ -50,6 +50,20 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/config/ConfigManager.cpp
|
src/config/ConfigManager.cpp
|
||||||
src/config/SecretStore.h
|
src/config/SecretStore.h
|
||||||
src/config/SecretStore.cpp
|
src/config/SecretStore.cpp
|
||||||
|
src/fileops/FileBackupManager.h
|
||||||
|
src/fileops/FileBackupManager.cpp
|
||||||
|
src/fileops/FileOperationManager.h
|
||||||
|
src/fileops/FileOperationManager.cpp
|
||||||
|
src/fileops/FileOperationTypes.h
|
||||||
|
src/fileops/FileSandbox.h
|
||||||
|
src/fileops/FileSandbox.cpp
|
||||||
|
src/launcher/AppDiscovery.h
|
||||||
|
src/launcher/AppDiscovery.cpp
|
||||||
|
src/launcher/AppLaunchManager.h
|
||||||
|
src/launcher/AppLaunchManager.cpp
|
||||||
|
src/launcher/AppLaunchStore.h
|
||||||
|
src/launcher/AppLaunchStore.cpp
|
||||||
|
src/launcher/AppLaunchTypes.h
|
||||||
src/notification/NotificationDispatcher.h
|
src/notification/NotificationDispatcher.h
|
||||||
src/notification/NotificationDispatcher.cpp
|
src/notification/NotificationDispatcher.cpp
|
||||||
src/reminder/ReminderCommandHandler.h
|
src/reminder/ReminderCommandHandler.h
|
||||||
@@ -68,6 +82,8 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/reminder/ReminderTypes.cpp
|
src/reminder/ReminderTypes.cpp
|
||||||
src/state/PetStateMachine.h
|
src/state/PetStateMachine.h
|
||||||
src/state/PetStateMachine.cpp
|
src/state/PetStateMachine.cpp
|
||||||
|
src/system/StartupManager.h
|
||||||
|
src/system/StartupManager.cpp
|
||||||
src/tray/TrayController.h
|
src/tray/TrayController.h
|
||||||
src/tray/TrayController.cpp
|
src/tray/TrayController.cpp
|
||||||
src/ui/ChatBubble.h
|
src/ui/ChatBubble.h
|
||||||
@@ -86,6 +102,24 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/util/Logger.cpp
|
src/util/Logger.cpp
|
||||||
src/util/ResourcePaths.h
|
src/util/ResourcePaths.h
|
||||||
src/util/ResourcePaths.cpp
|
src/util/ResourcePaths.cpp
|
||||||
|
src/weather/WeatherConfig.h
|
||||||
|
src/weather/WeatherManager.h
|
||||||
|
src/weather/WeatherManager.cpp
|
||||||
|
src/weather/WeatherParser.h
|
||||||
|
src/weather/WeatherParser.cpp
|
||||||
|
src/weather/WeatherStore.h
|
||||||
|
src/weather/WeatherStore.cpp
|
||||||
|
src/weather/WeatherSummaryFormatter.h
|
||||||
|
src/weather/WeatherSummaryFormatter.cpp
|
||||||
|
src/weather/WeatherTypes.h
|
||||||
|
src/web/WebCapabilityDetector.h
|
||||||
|
src/web/WebCapabilityDetector.cpp
|
||||||
|
src/web/WebChatManager.h
|
||||||
|
src/web/WebChatManager.cpp
|
||||||
|
src/web/WebChatTypes.h
|
||||||
|
src/web/WebConfig.h
|
||||||
|
src/web/WebStore.h
|
||||||
|
src/web/WebStore.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(QtDesktopPet
|
target_compile_definitions(QtDesktopPet
|
||||||
|
|||||||
+578
@@ -0,0 +1,578 @@
|
|||||||
|
# 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 定位兜底和模板回复
|
||||||
|
- 本地文件操作 v1:读取文本文件、列出文件夹、复制、备份、重命名
|
||||||
|
- 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding
|
||||||
|
- 应用启动 v1:通过聊天打开已登记应用、开始菜单快捷方式或用户确认选择的 `.exe`
|
||||||
|
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
||||||
|
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
|
||||||
|
|
||||||
|
尚未实现:
|
||||||
|
|
||||||
|
- 长期性能压测记录
|
||||||
|
- 发布包实机安装/卸载验证
|
||||||
|
- 文件操作 zip 打包、删除、覆盖、移动、脚本/命令执行
|
||||||
|
- 搜索网页全文抓取、长期缓存和浏览器自动打开网页
|
||||||
|
- 应用启动脚本、命令行参数、管理员权限和跨平台应用发现
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 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,内置音效和非法路径不会被删除
|
||||||
|
|
||||||
|
## 本地文件操作
|
||||||
|
|
||||||
|
本地文件操作通过聊天意图触发,但不会直接使用聊天文本里的路径。所有文件和文件夹都必须由用户在系统文件选择框中主动选择。
|
||||||
|
|
||||||
|
当前 v1 支持:
|
||||||
|
|
||||||
|
- 读取用户选择的常见文本文件:txt、md、log、json、csv、ini、xml、yaml
|
||||||
|
- 列出用户选择的文件夹,最多显示前 200 项
|
||||||
|
- 复制用户选择的文件到用户选择的目标文件夹
|
||||||
|
- 为用户选择的文件创建同目录备份
|
||||||
|
- 重命名用户选择的文件
|
||||||
|
|
||||||
|
安全规则:
|
||||||
|
|
||||||
|
- 写操作会先展示操作计划并要求确认
|
||||||
|
- 不覆盖已有文件
|
||||||
|
- 不删除文件
|
||||||
|
- 不移动大量文件
|
||||||
|
- 不执行脚本或命令
|
||||||
|
- 不访问 Windows 系统目录
|
||||||
|
- 拒绝符号链接或符号链接目录内的路径
|
||||||
|
|
||||||
|
暂不支持:
|
||||||
|
|
||||||
|
- zip 打包
|
||||||
|
- 删除、覆盖、移动
|
||||||
|
- 修改源码
|
||||||
|
- 执行脚本或命令
|
||||||
|
|
||||||
|
## 联网模式
|
||||||
|
|
||||||
|
联网模式不是搜索引擎聚合器。用户在输入框打开“联网”开关后,普通聊天会进入当前 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。
|
||||||
|
|
||||||
|
## 应用启动
|
||||||
|
|
||||||
|
应用启动是独立能力,不归入本地文件操作。可通过聊天输入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
打开 Codex
|
||||||
|
启动酷狗音乐
|
||||||
|
帮我打开 VSCode
|
||||||
|
```
|
||||||
|
|
||||||
|
配置保存到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QStandardPaths::AppConfigLocation/launcher_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
设置页“应用启动”支持:
|
||||||
|
|
||||||
|
- 启用或关闭应用启动
|
||||||
|
- 未知应用允许用户手动选择 `.exe`
|
||||||
|
- 登记应用名称和别名
|
||||||
|
- 编辑、删除、测试启动已登记应用
|
||||||
|
|
||||||
|
安全边界:
|
||||||
|
|
||||||
|
- 启动前始终二次确认,确认内容包含应用名、路径、工作目录和来源
|
||||||
|
- 只允许启动 `.exe` 或开始菜单 `.lnk` 快捷方式
|
||||||
|
- 不执行 `.bat` / `.cmd` / `.ps1` / `.vbs` / `.js` / `.msi`
|
||||||
|
- 不执行聊天文本里的命令
|
||||||
|
- 不拼接聊天文本参数
|
||||||
|
- 不以管理员权限启动
|
||||||
|
- 手选 `.exe` 后可选择记住为当前名称,后续走登记应用匹配
|
||||||
|
|
||||||
|
## 配置和日志
|
||||||
|
|
||||||
|
应用配置保存到 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
|
||||||
|
launcher_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 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/`、`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,297 +1,127 @@
|
|||||||
# QtDesktopPet
|
# QtDesktopPet
|
||||||
|
|
||||||
QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目,当前已具备多状态 PNG 帧动画、托盘控制、角色包导入与切换、用户自定义大模型对话、设置面板和 Windows 发布打包能力。项目现阶段重点是完善稳定性、性能回归、角色管理和发布体验。
|
一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、本地提醒、天气查询、简单文件操作和本地应用启动等能力。
|
||||||
|
|
||||||
## 当前状态
|
> 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。
|
||||||
|
|
||||||
已实现:
|

|
||||||
|
|
||||||
- 透明无边框桌宠窗口
|
## Features
|
||||||
- 鼠标拖动
|
|
||||||
- 右键菜单退出和状态测试
|
|
||||||
- 置顶切换
|
|
||||||
- `resources/characters/shiroko` 默认角色包读取
|
|
||||||
- PNG 序列帧动画播放
|
|
||||||
- `idle` / `talk` / `think` / `sleep` / `happy` / `drag` / `error` 状态
|
|
||||||
- 托盘显示、隐藏、退出
|
|
||||||
- 单实例限制,重复启动会唤醒已有实例
|
|
||||||
- 隐藏时暂停动画,显示时恢复动画
|
|
||||||
- 保存窗口位置、置顶、缩放和性能设置
|
|
||||||
- 文件日志和基础轮转
|
|
||||||
- 设置窗口按当前屏幕居中弹出
|
|
||||||
- 应用设置:缩放、性能模式、隐藏暂停、懒加载
|
|
||||||
- 状态级动画预热和 LRU 缓存卸载
|
|
||||||
- AI Provider 分组配置
|
|
||||||
- 设置页内 AI 连通性测试
|
|
||||||
- Windows DPAPI 加密保存 API Key
|
|
||||||
- 非 Windows 环境经用户确认后明文保存 API Key
|
|
||||||
- OpenAI Compatible 聊天请求
|
|
||||||
- SSE 流式输出
|
|
||||||
- 聊天输入框
|
|
||||||
- AI 回复气泡
|
|
||||||
- 对话历史面板
|
|
||||||
- 内存历史上限和可选本地历史保存
|
|
||||||
- AI 请求取消和对话清空
|
|
||||||
- Google Gemini 原生聊天请求
|
|
||||||
- 角色文件夹导入和角色切换
|
|
||||||
- 删除用户导入角色
|
|
||||||
- 本地一次性和重复提醒:聊天创建、查询、取消,重启后 pending 提醒不丢
|
|
||||||
- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知
|
|
||||||
- 提醒音效切换、试听、用户 wav 导入和删除
|
|
||||||
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
|
||||||
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
|
|
||||||
|
|
||||||
尚未实现:
|
- 透明无边框桌宠窗口,支持拖动、置顶、托盘隐藏和单实例唤醒。
|
||||||
|
- 多状态 PNG 序列帧动画:`idle`、`talk`、`think`、`sleep`、`happy`、`drag`、`error`。
|
||||||
|
- 角色包导入、切换、导出和用户角色目录管理。
|
||||||
|
- AI 对话:
|
||||||
|
- OpenAI-compatible API
|
||||||
|
- Google Gemini API
|
||||||
|
- DeepSeek / Custom Provider 配置
|
||||||
|
- 流式输出、请求取消、对话历史面板
|
||||||
|
- Windows DPAPI 加密保存 API Key
|
||||||
|
- 联网模式:
|
||||||
|
- 输入框“联网”开关
|
||||||
|
- OpenAI 官方 Responses API Web Search
|
||||||
|
- Gemini Google Search grounding
|
||||||
|
- DeepSeek / Custom 默认提示不支持或无法确认原生联网
|
||||||
|
- 本地提醒:
|
||||||
|
- 一次性提醒
|
||||||
|
- 每天 / 每周 / 每月重复提醒
|
||||||
|
- 提醒音效导入、试听、切换
|
||||||
|
- 桌宠可见时气泡提示,隐藏或 AI 忙时系统通知
|
||||||
|
- 天气查询:
|
||||||
|
- Open-Meteo Forecast API
|
||||||
|
- Open-Meteo Geocoding API
|
||||||
|
- 默认城市、公网 IP 定位兜底、多候选提示
|
||||||
|
- 本地文件操作 v1:
|
||||||
|
- 读取文本文件
|
||||||
|
- 列出文件夹
|
||||||
|
- 复制、备份、重命名
|
||||||
|
- 写操作前二次确认
|
||||||
|
- 应用启动 v1:
|
||||||
|
- 聊天触发打开本地应用
|
||||||
|
- 支持已登记应用、开始菜单快捷方式和用户手选 `.exe`
|
||||||
|
- 启动前二次确认
|
||||||
|
- Windows 发布脚本和 Inno Setup 安装器脚本。
|
||||||
|
|
||||||
- 角色导出和更完整的管理界面
|
## Platform
|
||||||
- 对话历史导出/管理界面
|
|
||||||
- 长期性能压测记录
|
|
||||||
- 发布包实机安装/卸载验证
|
|
||||||
|
|
||||||
## 技术栈
|
当前主要目标平台是 Windows 10 / Windows 11。
|
||||||
|
|
||||||
|
项目中已有部分跨平台基础代码,但托盘通知、开机自启动、应用发现和安装器体验目前按 Windows 优先实现。
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
- C++17
|
- C++17
|
||||||
- Qt 6 Widgets
|
- Qt 6 Widgets
|
||||||
- Qt 6 Network
|
- Qt 6 Network
|
||||||
- Qt 6 Multimedia
|
- Qt 6 Multimedia
|
||||||
- CMake
|
- CMake
|
||||||
- PNG 图片序列帧
|
- JSON 配置
|
||||||
- JSON 配置文件
|
- PNG 序列帧动画
|
||||||
- Windows 10 / Windows 11 优先
|
- Inno Setup
|
||||||
|
|
||||||
## 构建
|
## Repository Layout
|
||||||
|
|
||||||
推荐环境:
|
```text
|
||||||
|
.
|
||||||
|
├── CMakeLists.txt
|
||||||
|
├── main.cpp
|
||||||
|
├── installer/ # Inno Setup script
|
||||||
|
├── resources/
|
||||||
|
│ ├── characters/ # Built-in character packages
|
||||||
|
│ ├── icons/
|
||||||
|
│ └── sounds/
|
||||||
|
├── src/
|
||||||
|
│ ├── ai/ # AI providers and conversation state
|
||||||
|
│ ├── assistant/ # Intent routing and command dispatch
|
||||||
|
│ ├── character/ # Character package loading and animation
|
||||||
|
│ ├── config/ # Config persistence
|
||||||
|
│ ├── fileops/ # Local file operations
|
||||||
|
│ ├── launcher/ # Local application launcher
|
||||||
|
│ ├── 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 diagnostic scripts
|
||||||
|
```
|
||||||
|
|
||||||
- Qt 6.5.3
|
## Build
|
||||||
|
|
||||||
|
Recommended environment:
|
||||||
|
|
||||||
|
- Qt 6.5+
|
||||||
- CMake 3.20+
|
- CMake 3.20+
|
||||||
- Ninja
|
- Ninja
|
||||||
- MinGW 11.2.0 或已配置好的 Qt MSVC Kit
|
- MinGW 11.2.0 or a configured Qt MSVC Kit
|
||||||
|
|
||||||
MinGW 示例:
|
Example with MinGW:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cmake -S . -B build/mingw-debug -G Ninja `
|
cmake -S . -B build/mingw-release -G Ninja `
|
||||||
-DCMAKE_BUILD_TYPE=Debug `
|
-DCMAKE_BUILD_TYPE=Release `
|
||||||
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 `
|
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64 `
|
||||||
-DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe `
|
-DCMAKE_C_COMPILER=D:/Qt/Tools/mingw1120_64/bin/gcc.exe `
|
||||||
-DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe
|
-DCMAKE_CXX_COMPILER=D:/Qt/Tools/mingw1120_64/bin/g++.exe
|
||||||
|
|
||||||
cmake --build build/mingw-debug
|
cmake --build build/mingw-release
|
||||||
```
|
```
|
||||||
|
|
||||||
如果使用 Qt Creator,也可以直接打开根目录 `CMakeLists.txt`,选择合适的 Qt Kit 后构建。
|
Qt Creator users can open `CMakeLists.txt` directly and build with a matching Qt Kit.
|
||||||
|
|
||||||
## 应用图标
|
## Package
|
||||||
|
|
||||||
当前应用图标位于:
|
After building a Release executable, package it with:
|
||||||
|
|
||||||
```text
|
|
||||||
resources/icons/app_icon.ico
|
|
||||||
resources/icons/app_icon_1024.png
|
|
||||||
```
|
|
||||||
|
|
||||||
`app_icon.ico` 用于窗口图标、托盘图标和 Windows exe 资源图标;托盘图标加载失败时会回退到默认角色包的 `preview.png`。`app_icon_1024.png` 作为高分辨率源图保留。
|
|
||||||
运行时会优先读取可执行文件同级的 `resources/icons/`,找不到时回退到源码目录下的 `resources/icons/`。Windows exe 图标需要重新构建后生效。
|
|
||||||
|
|
||||||
## 角色包
|
|
||||||
|
|
||||||
当前默认角色包位于:
|
|
||||||
|
|
||||||
```text
|
|
||||||
resources/characters/shiroko/
|
|
||||||
```
|
|
||||||
|
|
||||||
内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
QStandardPaths::AppDataLocation/characters/<characterId>/
|
|
||||||
```
|
|
||||||
|
|
||||||
角色包基本结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
resources/characters/shiroko/
|
|
||||||
character.json
|
|
||||||
preview.png
|
|
||||||
idle/
|
|
||||||
talk/
|
|
||||||
think/
|
|
||||||
sleep/
|
|
||||||
happy/
|
|
||||||
drag/
|
|
||||||
error/
|
|
||||||
```
|
|
||||||
|
|
||||||
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。
|
|
||||||
运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。
|
|
||||||
|
|
||||||
角色导入:
|
|
||||||
|
|
||||||
- 只支持导入本地文件夹,不支持 zip
|
|
||||||
- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件
|
|
||||||
- 验证通过后复制到用户数据目录
|
|
||||||
- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名
|
|
||||||
- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾
|
|
||||||
- 用户角色同名时会询问是否覆盖
|
|
||||||
- 内置角色 id 不能被导入包覆盖
|
|
||||||
- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG
|
|
||||||
- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入
|
|
||||||
- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作
|
|
||||||
|
|
||||||
懒加载现状:
|
|
||||||
|
|
||||||
- `enableLazyLoad=true` 时,启动阶段只建立状态到帧路径的索引
|
|
||||||
- 某个状态首次播放时加载该状态的 PNG 帧
|
|
||||||
- 启动后会在主线程按批次预热常用状态,避免一次性加载全部帧
|
|
||||||
- 已加载状态按状态级 LRU 策略管理,超过动画缓存上限时卸载非保护状态
|
|
||||||
- 单轮预热不会反复重新加载刚被 LRU 卸载的状态,避免缓存上限较低时出现加载/卸载循环
|
|
||||||
- 隐藏到托盘时可释放非保护动画缓存
|
|
||||||
- `enableLazyLoad=false` 时仍保持启动阶段加载全部状态帧的兼容行为
|
|
||||||
|
|
||||||
## 定时提醒和音效
|
|
||||||
|
|
||||||
当前支持通过聊天输入创建一次性和重复本地提醒,例如:
|
|
||||||
|
|
||||||
```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,内置音效和非法路径不会被删除
|
|
||||||
|
|
||||||
## 配置和日志
|
|
||||||
|
|
||||||
应用配置保存到 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/
|
|
||||||
resources/sounds/
|
|
||||||
LICENSE
|
|
||||||
README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会调用 `windeployqt.exe` 收集 Qt 运行库。若当前 PATH 找不到 `windeployqt.exe`,需要指定 Qt bin 目录下的工具路径:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||||
-ExePath build/release/QtDesktopPet.exe `
|
-ExePath build/release/QtDesktopPet.exe
|
||||||
-WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
生成 Inno Setup 安装器:
|
To generate the Inno Setup installer:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||||
@@ -299,113 +129,108 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
|||||||
-BuildInstaller
|
-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`
|
||||||
|
- Launcher config: `launcher_config.json`
|
||||||
|
|
||||||
|
The app writes rotating logs under:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
QtDesktopPet-<version>-windows-x64-setup.exe
|
QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会先让 Inno Setup 输出到当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再把最终安装包复制回项目根目录,避免中文项目路径下出现 `EndUpdateResource failed (5)`。如果需要改变最终安装包目录,可传入 `-InstallerOutputDir`:
|
## AI And Privacy
|
||||||
|
|
||||||
|
QtDesktopPet only sends chat content to the AI endpoint configured by the user.
|
||||||
|
|
||||||
|
Important notes:
|
||||||
|
|
||||||
|
- API keys are not logged.
|
||||||
|
- Authorization headers are not logged.
|
||||||
|
- Full user messages and full error bodies should not be logged.
|
||||||
|
- On Windows, API keys are saved with DPAPI when available.
|
||||||
|
- Third-party compatible APIs and proxy services are controlled by the user; this project cannot guarantee their privacy behavior.
|
||||||
|
|
||||||
|
## Safety Boundaries
|
||||||
|
|
||||||
|
The project intentionally keeps local automation conservative:
|
||||||
|
|
||||||
|
- File operations require user-selected paths.
|
||||||
|
- Write operations require confirmation.
|
||||||
|
- File operations do not execute scripts or commands.
|
||||||
|
- Application launch only supports `.exe` and Start Menu `.lnk` shortcuts.
|
||||||
|
- Chat text is not converted into shell commands.
|
||||||
|
- Startup integration writes only the current user's Windows `Run` entry.
|
||||||
|
|
||||||
|
## Web Mode
|
||||||
|
|
||||||
|
Web mode is an AI-native conversation feature, not a search engine scraper.
|
||||||
|
|
||||||
|
Supported:
|
||||||
|
|
||||||
|
- OpenAI official API with Responses API Web Search.
|
||||||
|
- Google Gemini API with Google Search grounding.
|
||||||
|
|
||||||
|
Not treated as supported native web access:
|
||||||
|
|
||||||
|
- DeepSeek official API
|
||||||
|
- Custom OpenAI-compatible endpoints
|
||||||
|
- Third-party relay APIs
|
||||||
|
|
||||||
|
Unsupported providers show an explicit message instead of falling back to unreliable search-page scraping.
|
||||||
|
|
||||||
|
## Character Packages
|
||||||
|
|
||||||
|
Built-in characters are placed under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
resources/characters/<characterId>/
|
||||||
|
```
|
||||||
|
|
||||||
|
A character package contains:
|
||||||
|
|
||||||
|
```text
|
||||||
|
character.json
|
||||||
|
preview.png
|
||||||
|
idle/
|
||||||
|
talk/
|
||||||
|
think/
|
||||||
|
sleep/
|
||||||
|
happy/
|
||||||
|
drag/
|
||||||
|
error/
|
||||||
|
```
|
||||||
|
|
||||||
|
User-imported characters are copied to the user's app data directory instead of the installation directory.
|
||||||
|
|
||||||
|
## Public Export
|
||||||
|
|
||||||
|
This development workspace may contain internal planning and test documents. To create a clean public GitHub export, use:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools/prepare_github_export.ps1 `
|
||||||
-ExePath build/release/QtDesktopPet.exe `
|
-OutputDir D:\DesktopPet-github-export
|
||||||
-BuildInstaller `
|
|
||||||
-InstallerOutputDir D:\ReleaseOutput
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。
|
The export excludes internal docs, reports, build outputs, release packages, local config, logs and Git metadata.
|
||||||
|
|
||||||
本地生成的安装包也可以集中放到 `release_packages/`:
|
## License
|
||||||
|
|
||||||
```powershell
|
Source code is released under the MIT License. See [LICENSE](LICENSE).
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
|
||||||
-ExePath build/release/QtDesktopPet.exe `
|
|
||||||
-BuildInstaller `
|
|
||||||
-InstallerOutputDir release_packages
|
|
||||||
```
|
|
||||||
|
|
||||||
`dist/` 和 `release_packages/` 都是本地发布产物目录,不进入 Git。
|
Character art, icons, sounds and other media assets may have separate copyright requirements. Confirm asset licensing before public redistribution.
|
||||||
|
|
||||||
脚本默认优先查找:
|
|
||||||
|
|
||||||
```text
|
|
||||||
D:\Inno Setup 7\ISCC.exe
|
|
||||||
D:\Inno Setup 6\ISCC.exe
|
|
||||||
C:\Program Files (x86)\Inno Setup 7\ISCC.exe
|
|
||||||
C:\Program Files (x86)\Inno Setup 6\ISCC.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
如果 Inno Setup 安装在其他位置,可传入 `-InnoCompilerPath`。
|
|
||||||
|
|
||||||
安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 QtDesktopPet 数据目录。
|
|
||||||
|
|
||||||
## 开发诊断
|
|
||||||
|
|
||||||
仓库提供开发用性能采样脚本,不进入普通用户发布包:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到:
|
|
||||||
|
|
||||||
```text
|
|
||||||
reports/perf/
|
|
||||||
```
|
|
||||||
|
|
||||||
`reports/perf/` 已加入 `.gitignore`。稳定性检查记录模板见:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/performance_stability_check.md
|
|
||||||
```
|
|
||||||
|
|
||||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`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)。
|
|
||||||
|
|||||||
@@ -32,12 +32,16 @@
|
|||||||
- 删除用户导入角色
|
- 删除用户导入角色
|
||||||
- 本地一次性/重复提醒、提醒列表、取消提醒和到点通知
|
- 本地一次性/重复提醒、提醒列表、取消提醒和到点通知
|
||||||
- 内置/用户提醒音效切换、导入、删除和试听
|
- 内置/用户提醒音效切换、导入、删除和试听
|
||||||
|
- 天气查询、默认城市、公网 IP 定位兜底和多候选提示
|
||||||
|
- 本地文件操作安全入口:读取文本、列目录、复制、备份、重命名
|
||||||
|
- 联网模式:输入框开关、OpenAI/Gemini 原生联网、DeepSeek/Custom 不支持提示
|
||||||
|
- 本地应用启动:登记应用、开始菜单 / App Paths 发现、手选 `.exe` 和二次确认
|
||||||
- Windows 打包脚本和 Inno Setup 安装器脚本
|
- Windows 打包脚本和 Inno Setup 安装器脚本
|
||||||
- Release exe 双击不弹控制台窗口
|
- Release exe 双击不弹控制台窗口
|
||||||
|
|
||||||
项目已经从早期 MVP 进入到“可扩展桌面应用原型”阶段,可以开始规划工具能力扩展。
|
项目已经从早期 MVP 进入到“可扩展桌面应用原型”阶段,可以开始规划工具能力扩展。
|
||||||
|
|
||||||
但是,在正式加入定时提醒、天气、本地文件操作、联网搜索之前,建议先做一轮结构收口。
|
但是,在正式加入定时提醒、天气、本地文件操作、联网模式之前,建议先做一轮结构收口。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,7 +102,7 @@ loadCharacterPackage() 重新构建动画状态并从 idle 状态开始播放
|
|||||||
```text
|
```text
|
||||||
提醒解析
|
提醒解析
|
||||||
天气查询
|
天气查询
|
||||||
联网搜索
|
联网模式
|
||||||
本地文件读写
|
本地文件读写
|
||||||
AI 工具调度
|
AI 工具调度
|
||||||
复杂业务状态管理
|
复杂业务状态管理
|
||||||
@@ -126,7 +130,8 @@ ToolCommandDispatcher
|
|||||||
定时提醒
|
定时提醒
|
||||||
天气查询
|
天气查询
|
||||||
本地文件操作
|
本地文件操作
|
||||||
联网搜索
|
联网模式
|
||||||
|
本地应用启动
|
||||||
普通 AI 对话
|
普通 AI 对话
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,7 +170,7 @@ enum class UserIntentType
|
|||||||
Reminder,
|
Reminder,
|
||||||
Weather,
|
Weather,
|
||||||
FileOperation,
|
FileOperation,
|
||||||
Search
|
LaunchApp
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -178,7 +183,7 @@ IntentRouter 判断意图
|
|||||||
↓
|
↓
|
||||||
CommandDispatcher 分发
|
CommandDispatcher 分发
|
||||||
↓
|
↓
|
||||||
ReminderManager / WeatherManager / FileOperationManager / WebSearchManager / ConversationManager
|
ReminderManager / WeatherManager / FileOperationManager / WebChatManager / AppLaunchManager / ConversationManager
|
||||||
```
|
```
|
||||||
|
|
||||||
第一版意图识别不需要复杂,规则优先即可。
|
第一版意图识别不需要复杂,规则优先即可。
|
||||||
@@ -192,7 +197,7 @@ ReminderManager / WeatherManager / FileOperationManager / WebSearchManager / Con
|
|||||||
推荐:
|
推荐:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Reminder > FileOperation > Weather > Search > Chat
|
Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat
|
||||||
```
|
```
|
||||||
|
|
||||||
原因:
|
原因:
|
||||||
@@ -205,7 +210,7 @@ Reminder > FileOperation > Weather > Search > Chat
|
|||||||
可能是文件操作,而不是单纯天气查询。
|
可能是文件操作,而不是单纯天气查询。
|
||||||
|
|
||||||
“搜索一下明天天气”
|
“搜索一下明天天气”
|
||||||
可能是搜索请求,但如果已有 WeatherTool,应优先走天气工具。
|
新版不再作为独立搜索工具处理;如果已有 WeatherTool,应优先走天气工具,否则按普通聊天或输入框联网开关进入 Chat/WebChat。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,7 +234,7 @@ set(CMAKE_AUTOMOC ON)
|
|||||||
|
|
||||||
当前阶段选择方案 B:继续保持 `CMAKE_AUTOMOC OFF`,新增意图分发模块使用普通 C++ 类和同步返回值,不引入 `Q_OBJECT`。
|
当前阶段选择方案 B:继续保持 `CMAKE_AUTOMOC OFF`,新增意图分发模块使用普通 C++ 类和同步返回值,不引入 `Q_OBJECT`。
|
||||||
|
|
||||||
后续如果 Reminder / Weather / Search 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。
|
后续如果 Reminder / Weather / WebChat 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -253,7 +258,7 @@ set(CMAKE_AUTOMOC ON)
|
|||||||
天气 API 请求说明
|
天气 API 请求说明
|
||||||
IP 定位隐私说明
|
IP 定位隐私说明
|
||||||
本地文件操作权限说明
|
本地文件操作权限说明
|
||||||
联网搜索来源和隐私说明
|
联网模式来源和隐私说明
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -292,7 +297,7 @@ IP 定位隐私说明
|
|||||||
阶段 1:定时提醒
|
阶段 1:定时提醒
|
||||||
阶段 2:天气查询
|
阶段 2:天气查询
|
||||||
阶段 3:本地文件操作
|
阶段 3:本地文件操作
|
||||||
阶段 4:联网搜索
|
阶段 4:联网模式
|
||||||
阶段 5:语音对话 / 更复杂 Agent 能力
|
阶段 5:语音对话 / 更复杂 Agent 能力
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -301,7 +306,7 @@ IP 定位隐私说明
|
|||||||
```text
|
```text
|
||||||
1. 天气查询
|
1. 天气查询
|
||||||
2. 本地文件操作安全边界
|
2. 本地文件操作安全边界
|
||||||
3. 联网搜索
|
3. 联网模式
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -330,7 +335,7 @@ src/assistant/CommandDispatcher.cpp
|
|||||||
- 定时提醒
|
- 定时提醒
|
||||||
- 天气查询
|
- 天气查询
|
||||||
- 本地文件操作
|
- 本地文件操作
|
||||||
- 联网搜索
|
- 联网模式
|
||||||
```
|
```
|
||||||
|
|
||||||
第一版可以使用规则判断,不依赖 AI。
|
第一版可以使用规则判断,不依赖 AI。
|
||||||
@@ -634,9 +639,35 @@ Windows 托盘通知后端不可用:记录日志,不补气泡,不进入可
|
|||||||
|
|
||||||
# 6. 阶段 2:天气查询功能
|
# 6. 阶段 2:天气查询功能
|
||||||
|
|
||||||
|
当前天气查询 v1 已进入实现阶段:
|
||||||
|
|
||||||
|
- 已新增独立 `src/weather/` 模块
|
||||||
|
- 已支持 Open-Meteo 市级城市优先的基础地理编码和基础天气查询
|
||||||
|
- 已支持设置页默认城市
|
||||||
|
- 已支持默认城市为空时通过公网 IP 定位兜底
|
||||||
|
- 已支持当前/今天、明天、后天和未来 1-3 天模板回复
|
||||||
|
- 已支持读取前 5 个地理编码候选;多候选时仍查首项,并在回复中提示同名城市风险和其他候选
|
||||||
|
- 已支持设置页测试默认城市;测试只展示匹配结果,不自动保存配置
|
||||||
|
- 已通过 `CommandDispatcher` 将 Weather 意图接入聊天入口
|
||||||
|
- 已保持 `PetWindow` 只负责 UI 展示,不承载天气解析或网络逻辑
|
||||||
|
|
||||||
|
v1 明确暂不支持:
|
||||||
|
|
||||||
|
- AI 润色天气回复
|
||||||
|
- 空气质量
|
||||||
|
- 天气预警
|
||||||
|
- 天气提醒联动
|
||||||
|
- 多天气源切换
|
||||||
|
- 小时级精细降雨判断
|
||||||
|
- 穿衣指数
|
||||||
|
|
||||||
|
默认城市为空且启用公网 IP 定位时,会请求 ipapi.co 判断城市;回复必须说明“根据公网 IP 判断城市”。设置页默认城市和公网 IP 定位都属于非用户明确城市来源,回复必须说明来源。
|
||||||
|
|
||||||
|
当前 v1 推荐填写市级城市名;区县、乡镇、街道不保证精确识别,可能无法匹配或被匹配到上级/同名城市。同名城市当前使用天气源返回的第一个结果,并在回复或测试结果中提示其他候选。后续优先补可交互候选选择、区县级定位和国内天气源增强。
|
||||||
|
|
||||||
## 6.1 功能定位
|
## 6.1 功能定位
|
||||||
|
|
||||||
天气是独立工具能力,不放进联网搜索。
|
天气是独立工具能力,不放进联网模式。
|
||||||
|
|
||||||
正确流程:
|
正确流程:
|
||||||
|
|
||||||
@@ -654,9 +685,9 @@ AI 根据结构化数据自然语言回复
|
|||||||
|
|
||||||
不要让 AI 自己搜索天气,也不要让 AI 自己拼 URL。
|
不要让 AI 自己搜索天气,也不要让 AI 自己拼 URL。
|
||||||
|
|
||||||
## 6.2 为什么不用联网搜索做天气
|
## 6.2 为什么不用联网模式做天气
|
||||||
|
|
||||||
搜索天气有几个问题:
|
用联网模式查询天气有几个问题:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
结果格式不稳定
|
结果格式不稳定
|
||||||
@@ -706,10 +737,10 @@ src/weather/
|
|||||||
├── WeatherConfig.h
|
├── WeatherConfig.h
|
||||||
├── WeatherStore.h
|
├── WeatherStore.h
|
||||||
├── WeatherStore.cpp
|
├── WeatherStore.cpp
|
||||||
├── WeatherProvider.h
|
├── WeatherParser.h
|
||||||
├── WeatherProvider.cpp
|
├── WeatherParser.cpp
|
||||||
├── OpenMeteoWeatherProvider.h
|
├── WeatherSummaryFormatter.h
|
||||||
├── OpenMeteoWeatherProvider.cpp
|
├── WeatherSummaryFormatter.cpp
|
||||||
├── WeatherManager.h
|
├── WeatherManager.h
|
||||||
└── WeatherManager.cpp
|
└── WeatherManager.cpp
|
||||||
```
|
```
|
||||||
@@ -836,6 +867,8 @@ VPN / 代理会导致定位错误
|
|||||||
|
|
||||||
## 6.10 AI 回复上下文
|
## 6.10 AI 回复上下文
|
||||||
|
|
||||||
|
当前 v1 采用模板优先,不依赖 AI 润色;本节作为 v1.1 预留方向。
|
||||||
|
|
||||||
程序应把结构化天气数据交给 AI:
|
程序应把结构化天气数据交给 AI:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -872,6 +905,27 @@ VPN / 代理会导致定位错误
|
|||||||
|
|
||||||
本地文件操作是高风险功能,必须后置,不能早于提醒和天气。
|
本地文件操作是高风险功能,必须后置,不能早于提醒和天气。
|
||||||
|
|
||||||
|
当前本地文件操作 v1 已进入实现阶段:
|
||||||
|
|
||||||
|
- 已新增独立 `src/fileops/` 模块
|
||||||
|
- 已通过 `CommandDispatcher` 将 FileOperation 意图接入聊天入口
|
||||||
|
- 已支持读取用户主动选择的常见文本文件
|
||||||
|
- 已支持列出用户主动选择的文件夹
|
||||||
|
- 已支持复制文件、创建备份、重命名文件
|
||||||
|
- 写操作会展示操作计划并二次确认
|
||||||
|
- 聊天文本不会直接变成本地路径,所有路径必须由用户通过文件选择框选择
|
||||||
|
- 已拒绝删除、覆盖、移动、执行脚本、运行命令和系统目录访问
|
||||||
|
- 已拒绝符号链接路径,降低路径逃逸风险
|
||||||
|
|
||||||
|
v1 明确暂不支持:
|
||||||
|
|
||||||
|
- zip 打包
|
||||||
|
- 删除文件
|
||||||
|
- 覆盖文件
|
||||||
|
- 移动文件
|
||||||
|
- 执行脚本或命令
|
||||||
|
- 修改源码
|
||||||
|
|
||||||
目标是让桌宠能辅助用户处理本地文件,例如:
|
目标是让桌宠能辅助用户处理本地文件,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -930,8 +984,6 @@ AI 自己决定访问系统目录
|
|||||||
```text
|
```text
|
||||||
src/fileops/
|
src/fileops/
|
||||||
├── FileOperationTypes.h
|
├── FileOperationTypes.h
|
||||||
├── FileOperationPlanner.h
|
|
||||||
├── FileOperationPlanner.cpp
|
|
||||||
├── FileOperationManager.h
|
├── FileOperationManager.h
|
||||||
├── FileOperationManager.cpp
|
├── FileOperationManager.cpp
|
||||||
├── FileSandbox.h
|
├── FileSandbox.h
|
||||||
@@ -953,6 +1005,8 @@ src/fileops/
|
|||||||
重命名文件,需确认
|
重命名文件,需确认
|
||||||
```
|
```
|
||||||
|
|
||||||
|
当前 v1 中 zip 打包延期,因为 Qt 公共 API 没有稳定内置写 zip 能力;不为此引入重依赖。后续如果要补 zip,优先选择明确许可和可维护的压缩库。
|
||||||
|
|
||||||
暂不做:
|
暂不做:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -978,82 +1032,95 @@ D:/xxx/b.txt
|
|||||||
是否继续?
|
是否继续?
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 7.7 应用启动独立模块
|
||||||
|
|
||||||
# 8. 阶段 4:联网搜索功能
|
当前已新增独立 `src/launcher/` 模块,不放进 `src/fileops/`:
|
||||||
|
|
||||||
## 8.1 功能定位
|
|
||||||
|
|
||||||
联网搜索后置,不要现在优先做。
|
|
||||||
|
|
||||||
联网搜索是通用增强能力:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
查询最新信息
|
src/launcher/
|
||||||
查官网
|
├── AppLaunchTypes.h
|
||||||
查报错
|
├── AppLaunchStore.h
|
||||||
查新闻
|
├── AppLaunchStore.cpp
|
||||||
查版本
|
├── AppDiscovery.h
|
||||||
查文档
|
├── AppDiscovery.cpp
|
||||||
|
├── AppLaunchManager.h
|
||||||
|
└── AppLaunchManager.cpp
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8.2 不要给每家模型各自配置搜索
|
应用启动支持:
|
||||||
|
|
||||||
不建议:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
OpenAIProvider 自己一套搜索
|
打开 Codex
|
||||||
GeminiProvider 自己一套搜索
|
启动酷狗音乐
|
||||||
DeepSeekProvider 自己一套搜索
|
帮我打开 VSCode
|
||||||
CustomProvider 自己一套搜索
|
|
||||||
```
|
```
|
||||||
|
|
||||||
推荐:
|
解析和发现顺序:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
WebSearchManager 独立负责搜索
|
用户在设置页登记的应用别名
|
||||||
LLMProvider 只负责回答
|
↓
|
||||||
ConversationManager / CommandDispatcher 负责把搜索结果注入给 AI
|
Windows 开始菜单快捷方式
|
||||||
|
↓
|
||||||
|
Windows App Paths 注册表
|
||||||
|
↓
|
||||||
|
未知应用时由用户手动选择 .exe
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8.3 建议目录
|
启动安全边界:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/search/
|
启动前始终二次确认
|
||||||
├── SearchTypes.h
|
只允许 .exe 或开始菜单 .lnk
|
||||||
├── SearchProvider.h
|
不执行 .bat / .cmd / .ps1 / .vbs / .js / .msi
|
||||||
├── SearXNGSearchProvider.h
|
不执行聊天文本里的命令
|
||||||
├── SearXNGSearchProvider.cpp
|
不拼接聊天文本参数
|
||||||
├── WebSearchManager.h
|
不以管理员权限启动
|
||||||
└── WebSearchManager.cpp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
第一版只做:
|
配置保存到:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SearXNG + 自定义 Base URL
|
QStandardPaths::AppConfigLocation/launcher_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
后续再加:
|
损坏时备份为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tavily
|
launcher_config.broken.yyyyMMdd-HHmmss.json
|
||||||
Brave Search
|
|
||||||
CustomSearchProvider
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8.4 安全边界
|
|
||||||
|
|
||||||
```text
|
|
||||||
不要让 AI 自己生成任意 URL 给程序访问
|
|
||||||
不要做网页全文抓取第一版
|
|
||||||
不要默认每次都联网
|
|
||||||
不要把用户隐私查询写日志
|
|
||||||
搜索结果要显示来源
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# 8. 阶段 4:联网模式
|
||||||
|
|
||||||
|
联网能力已从旧“搜索引擎聚合器”重做为 Web 端 AI 对话式联网模式。
|
||||||
|
|
||||||
|
当前已落地:
|
||||||
|
|
||||||
|
- 已删除旧 `src/search/` 模块,不再维护 Google/百度/360/搜狗页面解析、SearXNG 旧配置、多源聚合和旧 `search_config.json` 兼容。
|
||||||
|
- 已新增独立 `src/web/` 模块:`WebConfig`、`WebStore`、`WebCapabilityDetector`、`WebChatManager` 和 Web citation 类型。
|
||||||
|
- 输入框新增“联网”开关;开关开启后,普通聊天进入 WebChat 流程。
|
||||||
|
- 支持 OpenAI 官方 Provider 的 Responses API Web Search。
|
||||||
|
- 支持 Google Gemini Provider 的 Google Search grounding。
|
||||||
|
- DeepSeek 官方 API 当前不提供托管联网搜索工具,显示“不支持原生联网”。
|
||||||
|
- Custom / 第三方 OpenAI-Compatible 默认无法确认联网能力,不发送未知联网参数,不做旧搜索页抓取。
|
||||||
|
- 设置页改为“联网模式”:显示当前能力状态、开关记忆、默认开关、Provider 模式、超时、来源展示和测试联网模式。
|
||||||
|
- 旧 `search_config.json` 已废弃;新版使用 `web_config.json`,损坏时备份为 `web_config.broken.yyyyMMdd-HHmmss.json`。
|
||||||
|
|
||||||
|
边界:
|
||||||
|
|
||||||
|
- 不再默认每次联网,模型可判断稳定常识无需联网。
|
||||||
|
- 不做搜索结果页 HTML 解析,避免登录、帮助、反馈、验证码页面污染答案。
|
||||||
|
- 不实现 Tavily / Brave / Bing Search API、自建搜索后端、网页全文抓取或长期缓存。
|
||||||
|
- 天气仍是独立工具能力,不放进联网模式。
|
||||||
|
|
||||||
|
后续可选:
|
||||||
|
|
||||||
|
- 更多 AI Provider 原生联网适配。
|
||||||
|
- 结构化搜索 API 或自建联网后端,用于不支持原生联网的模型。
|
||||||
|
- 更强的引用 UI、证据片段摘录和来源可点击展示。
|
||||||
|
|
||||||
# 9. 建议最终开发顺序
|
# 9. 建议最终开发顺序
|
||||||
|
|
||||||
## 9.1 第一步:收口架构
|
## 9.1 第一步:收口架构
|
||||||
@@ -1076,33 +1143,30 @@ CustomSearchProvider
|
|||||||
## 9.3 第三步:天气查询
|
## 9.3 第三步:天气查询
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. WeatherTypes
|
当前已落地天气 v1:WeatherTypes、WeatherConfig / WeatherStore、WeatherParser、WeatherManager、模板回复、设置页默认城市、公网 IP 定位兜底和 Weather 意图接入。
|
||||||
2. WeatherConfig / WeatherStore
|
当前已落地天气 v1.2 / v1.3:多候选城市提示和设置页默认城市测试。后续可继续补可交互候选选择、区县级定位、国内天气源增强、AI 润色、空气质量、天气预警、多天气源切换、小时级降雨判断和天气提醒联动。
|
||||||
3. OpenMeteoWeatherProvider
|
|
||||||
4. WeatherManager
|
|
||||||
5. IntentRouter 天气识别
|
|
||||||
6. AI 回复 / 模板兜底
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9.4 第四步:本地文件操作
|
## 9.4 第四步:本地文件操作
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. FileSandbox
|
当前已落地 FileSandbox、FileOperationTypes、FileOperationManager、FileBackupManager 和用户确认 UI。
|
||||||
2. FileOperationTypes
|
v1 支持读取文本、列出文件夹、复制、备份、重命名。
|
||||||
3. FileOperationPlanner
|
v1 不支持 zip、删除、覆盖、移动、脚本/命令执行和系统目录访问。
|
||||||
4. FileOperationManager
|
|
||||||
5. 用户确认 UI
|
|
||||||
6. 备份机制
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9.5 第五步:联网搜索
|
## 9.5 第五步:联网模式
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. SearchTypes
|
当前已落地 WebConfig、WebStore、WebCapabilityDetector、WebChatManager 和输入框联网开关。新版支持 OpenAI/Gemini 原生联网;DeepSeek/Custom 明确提示不支持或无法确认;旧搜索聚合与 SearXNG 旧配置已废弃。
|
||||||
2. SearchProvider
|
```
|
||||||
3. SearXNGSearchProvider
|
|
||||||
4. WebSearchManager
|
## 9.6 第六步:应用启动
|
||||||
5. 来源展示
|
|
||||||
|
```text
|
||||||
|
当前已落地 AppLaunchStore、AppDiscovery、AppLaunchManager 和设置页应用登记。
|
||||||
|
v1 支持已登记应用、开始菜单快捷方式、App Paths 注册表和用户确认手选 .exe。
|
||||||
|
v1 不支持脚本、聊天参数、命令执行、管理员权限和跨平台应用发现。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1112,17 +1176,17 @@ CustomSearchProvider
|
|||||||
下面这段可以直接作为后续任务总说明:
|
下面这段可以直接作为后续任务总说明:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
当前不要急着直接实现提醒、天气、文件操作或联网搜索。
|
当前不要急着直接实现提醒、天气、文件操作或联网模式。
|
||||||
|
|
||||||
请先审查当前 QtDesktopPet 项目结构,并完成以下准备工作:
|
请先审查当前 QtDesktopPet 项目结构,并完成以下准备工作:
|
||||||
|
|
||||||
1. 角色切换在设置保存后已确认立即生效,后续只需保留回归检查。
|
1. 角色切换在设置保存后已确认立即生效,后续只需保留回归检查。
|
||||||
2. 新增 IntentRouter / CommandDispatcher,使用户输入先经过统一意图分发。
|
2. 新增 IntentRouter / CommandDispatcher,使用户输入先经过统一意图分发。
|
||||||
3. 意图类型包括 Chat、Reminder、Weather、FileOperation、Search。
|
3. 意图类型包括 Chat、Reminder、Weather、FileOperation、LaunchApp;联网模式由输入框开关决定,不再是独立 Search 意图。
|
||||||
4. 意图优先级为 Reminder > FileOperation > Weather > Search > Chat。
|
4. 意图优先级为 Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat。
|
||||||
5. 普通聊天继续走现有 AI 对话流程。
|
5. 普通聊天继续走现有 AI 对话流程。
|
||||||
6. 新功能不得继续直接塞进 PetWindow。
|
6. 新功能不得继续直接塞进 PetWindow。
|
||||||
7. 后续 Reminder、Weather、FileOperation、Search 均应作为独立模块接入。
|
7. 后续 Reminder、Weather、FileOperation、LaunchApp、WebChat 均应作为独立模块接入。
|
||||||
8. 当前阶段保持 CMAKE_AUTOMOC OFF,后续模块不要使用 Q_OBJECT;确需 Qt 信号槽时再单独评估。
|
8. 当前阶段保持 CMAKE_AUTOMOC OFF,后续模块不要使用 Q_OBJECT;确需 Qt 信号槽时再单独评估。
|
||||||
9. 保持现有隐私策略:API Key、Authorization、完整用户消息、完整错误响应不得写入日志。
|
9. 保持现有隐私策略:API Key、Authorization、完整用户消息、完整错误响应不得写入日志。
|
||||||
10. 保持现有文件安全策略:路径校验、危险操作确认、修改前备份。
|
10. 保持现有文件安全策略:路径校验、危险操作确认、修改前备份。
|
||||||
@@ -1137,7 +1201,7 @@ CustomSearchProvider
|
|||||||
建议先做:
|
建议先做:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网搜索
|
结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网模式
|
||||||
```
|
```
|
||||||
|
|
||||||
其中:
|
其中:
|
||||||
@@ -1146,5 +1210,5 @@ CustomSearchProvider
|
|||||||
定时提醒:已作为第一个工具能力落地
|
定时提醒:已作为第一个工具能力落地
|
||||||
天气:建议第二个落地
|
天气:建议第二个落地
|
||||||
本地文件操作:风险较高,第三个落地
|
本地文件操作:风险较高,第三个落地
|
||||||
联网搜索:通用能力,最后落地
|
联网模式:通用 AI 对话增强,最后落地
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# QtDesktopPet 测试清单与验收标准
|
||||||
|
|
||||||
|
本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。
|
||||||
|
|
||||||
|
## 验收记录格式
|
||||||
|
|
||||||
|
每个用例建议记录:
|
||||||
|
|
||||||
|
```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 |
|
||||||
|
| 导入合法角色 | 导入有效角色文件夹 | 验证通过后复制到用户数据目录,可选择使用 | 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 |
|
||||||
|
|
||||||
|
## 定时提醒
|
||||||
|
|
||||||
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 一次性提醒 | `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 |
|
||||||
|
|
||||||
|
## 天气查询
|
||||||
|
|
||||||
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 明确城市 | `西安天气怎么样` | 返回 Open-Meteo 模板结果 | TODO |
|
||||||
|
| 明天 | `明天西安天气怎么样` | 返回明天预报 | TODO |
|
||||||
|
| 后天 | `后天纽约天气` | 返回后天预报 | TODO |
|
||||||
|
| 未来三天 | `未来三天北京天气` | 返回 1-3 天摘要 | TODO |
|
||||||
|
| 默认城市 | 设置默认城市后输入 `今天天气怎么样` | 使用默认城市并说明来源 | TODO |
|
||||||
|
| IP fallback | 清空默认城市并启用 IP 定位 | 使用公网 IP 城市并说明来源 | TODO |
|
||||||
|
| 禁用 IP fallback | 清空默认城市并关闭 IP 定位 | 提示配置默认城市或在问题中带城市 | TODO |
|
||||||
|
| 多候选 | 查询同名城市 | 使用首项并提示其他候选 | TODO |
|
||||||
|
| 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO |
|
||||||
|
| 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO |
|
||||||
|
| 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO |
|
||||||
|
|
||||||
|
## 本地文件操作
|
||||||
|
|
||||||
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 读取文本 | 输入读取文本文件请求并选择 txt/md/log | 显示内容预览,最多读取前 64KB | TODO |
|
||||||
|
| 非文本拒绝 | 选择 exe/png 等非文本 | 提示只允许常见文本文件 | TODO |
|
||||||
|
| 列目录 | 输入列出文件夹请求并选择临时目录 | 显示前 200 项,区分目录/文件 | TODO |
|
||||||
|
| 复制文件 | 选择源文件和目标目录 | 显示计划,确认后复制;目标存在则拒绝覆盖 | TODO |
|
||||||
|
| 创建备份 | 选择文件备份 | 显示计划,确认后生成 `.backup.yyyyMMdd-HHmmss` 文件 | TODO |
|
||||||
|
| 重命名 | 选择文件并输入新文件名 | 显示计划,确认后重命名;目标存在拒绝 | TODO |
|
||||||
|
| 危险词拒绝 | 输入删除/覆盖/移动/执行脚本/运行命令 | 明确拒绝,不弹路径选择,不做文件操作 | TODO |
|
||||||
|
| 系统目录拒绝 | 尝试选择 Windows/Program Files 下路径 | 提示不允许访问系统目录 | TODO |
|
||||||
|
| 符号链接拒绝 | 选择符号链接或链接目录内路径 | 提示不允许操作符号链接路径 | TODO |
|
||||||
|
| zip 延期 | 输入打包/压缩请求 | 提示 zip 打包暂不启用 | 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 |
|
||||||
|
|
||||||
|
## 应用启动
|
||||||
|
|
||||||
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 设置页登记 | 添加一个 `.exe`,填写名称和别名后保存 | 重启后登记项仍存在 | TODO |
|
||||||
|
| 已登记启动 | 输入 `打开 <别名>` | 弹出确认框,确认后启动对应 exe | TODO |
|
||||||
|
| 未知应用手选 | 输入 `打开酷狗音乐` 且未登记 | 弹出 exe 选择器,选择 `.exe` 后弹确认框 | TODO |
|
||||||
|
| 记住手选应用 | 手选 exe 时勾选记住 | 后续同名请求直接命中登记应用并仍需确认 | TODO |
|
||||||
|
| 用户取消选择 | 未知应用弹出选择器后取消 | 不启动任何程序,无配置写入 | TODO |
|
||||||
|
| 用户取消确认 | 选择或命中应用后取消确认 | 不启动任何程序 | TODO |
|
||||||
|
| 拒绝脚本 | 尝试选择 `.bat/.cmd/.ps1/.vbs/.js/.msi` | 被拒绝,不启动 | TODO |
|
||||||
|
| 不执行命令 | 输入“运行 powershell 命令” | 不执行聊天文本命令;按安全规则拒绝或走普通聊天 | TODO |
|
||||||
|
| 文件操作优先 | 输入 `打开文件夹` | 走文件操作或文件操作拒绝路径,不走应用启动 | TODO |
|
||||||
|
| 设置页测试启动 | 选中登记应用点测试启动 | 先二次确认,确认后启动 | TODO |
|
||||||
|
| 设置页开机自启动 | 应用设置页勾选/取消“开机自启动”并保存 | 当前用户 Run 注册表项写入/移除,重开设置页状态与实际注册表一致 | TODO |
|
||||||
|
|
||||||
|
## 发布与性能
|
||||||
|
|
||||||
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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 -> PetWindow::handleFileOperationChatMessage() -> FileOperationManager` | 用户通过系统对话框选择路径;读取文本、列目录、复制、备份、重命名;写操作先展示计划并二次确认 | 删除/覆盖/移动/脚本/命令/截图保存/zip 直接拒绝;系统目录、符号链接、目标冲突、非法文件名均拒绝 | 文件选择对话框是安全边界;系统目录根据常见路径和环境变量识别 | v1 安全能力完成,zip 打包延期,不引入重依赖 |
|
||||||
|
| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;不支持 DeepSeek/Custom 伪联网 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 |
|
||||||
|
| 应用启动 | `CommandDispatcher -> PetWindow::handleLaunchAppChatMessage() -> AppLaunchManager` | 用户输入打开/启动应用后,按登记别名、开始菜单、App Paths、手选 exe 顺序解析,启动前二次确认 | 配置关闭、未找到应用、用户取消、非 exe/lnk、脚本/安装包、路径不存在均不启动;手选记住失败只提示,不影响已确认启动 | 启动成功由系统返回值判断,目标应用自身异常不由桌宠控制;开始菜单和注册表发现仅 Windows 优先 | v1 完成,不支持脚本、命令行参数、管理员权限、跨平台发现 |
|
||||||
|
| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;性能文档覆盖静置、AI、提醒、天气、联网模式、文件操作 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 |
|
||||||
@@ -1696,8 +1696,9 @@ MIT License 开源
|
|||||||
当前仍需补齐:
|
当前仍需补齐:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 角色包导出和更完整管理界面
|
1. 发布前素材授权确认与打包验证
|
||||||
2. 对话历史导出、搜索或更完整管理界面
|
2. 长期性能压测记录
|
||||||
3. 发布前素材授权确认与打包验证
|
3. 本地文件操作 zip 打包能力,如后续确认压缩库方案再补
|
||||||
4. 长期性能压测记录
|
4. 联网模式后续可补更多 AI Provider 原生联网适配、结构化搜索 API 或自建联网后端;网页全文抓取和长期缓存仍需先确认安全边界
|
||||||
|
5. 应用启动跨平台发现、脚本/命令执行和管理员权限当前不支持,后续如确需增加必须重新评估安全边界
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -597,8 +597,8 @@ release_packages/
|
|||||||
当前实现与计划仍存在差异:
|
当前实现与计划仍存在差异:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程
|
1. SettingsDialog 已支持角色导入、切换、删除用户角色、导出角色和打开用户角色目录;后续更复杂的角色市场/分享仍不在当前范围
|
||||||
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
|
2. 对话历史已有内存上限、可选本地保存、关键词搜索、Provider/模型筛选和 Markdown/JSON 导出
|
||||||
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
|
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
|
||||||
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
||||||
```
|
```
|
||||||
@@ -636,12 +636,12 @@ release_packages/
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. 完善设置界面:
|
1. 完善设置界面:
|
||||||
- 角色导出
|
|
||||||
- 打开用户角色目录
|
|
||||||
- 更完整的角色管理状态提示
|
- 更完整的角色管理状态提示
|
||||||
|
- 后续如果做角色分享,需要补版权提示和包格式校验
|
||||||
2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录
|
2. 使用 tools/perf_sample.ps1 补一轮可重复的稳定性与性能测试记录
|
||||||
3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略
|
3. 使用 tools/perf_sample.ps1 验证状态级 LRU 卸载、主线程分批预热和动画缓存上限策略
|
||||||
4. 做发布包安装/卸载实机验证,并确认 release_packages/ 或根目录安装包输出规则
|
4. 做发布包安装/卸载实机验证,并确认 release_packages/ 或根目录安装包输出规则
|
||||||
|
5. 按 docs/QtDesktopPet_测试清单与验收标准.md 做全功能手测
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -652,6 +652,6 @@ release_packages/
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
||||||
2. 角色管理下一步是否需要导出、打开用户角色目录
|
2. 角色管理后续是否需要角色分享/市场能力
|
||||||
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
|
3. 对话历史后续是否需要更复杂的会话分组、归档或全文索引
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ reports/perf/
|
|||||||
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
||||||
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | 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 时检查脱敏摘要 |
|
| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
|
||||||
|
| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `tools/perf_sample.ps1 -DurationSeconds 300` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 |
|
||||||
|
| 天气查询 | 连续查询当前/明天/未来三天天气 | `tools/perf_sample.ps1 -DurationSeconds 300` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 |
|
||||||
|
| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `tools/perf_sample.ps1 -DurationSeconds 300` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 |
|
||||||
|
| 应用启动 | 登记一个测试 exe,聊天触发启动并取消/确认各一次 | 手工观察 + 采样 | 启动前始终二次确认;取消时不启动;确认后 UI 可继续响应;不执行脚本或聊天参数 | TODO | TODO | 使用无副作用测试程序,例如记事本或临时测试 exe |
|
||||||
|
| 本地文件操作 | 读取文本、列目录、复制、备份、重命名 | 手工观察 + 采样 | 写操作有确认;不覆盖、不删除、不访问系统目录;操作后 UI 可继续响应 | TODO | TODO | 使用临时测试目录,不操作真实重要文件 |
|
||||||
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
|
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
|
||||||
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
|
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
|
||||||
|
|
||||||
@@ -99,6 +104,7 @@ QtDesktopPet.exe
|
|||||||
Qt 运行时依赖
|
Qt 运行时依赖
|
||||||
resources/characters/
|
resources/characters/
|
||||||
resources/icons/
|
resources/icons/
|
||||||
|
resources/sounds/
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ UninstallDisplayIcon={app}\QtDesktopPet.exe
|
|||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
|
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
|
||||||
|
Name: "startup"; Description: "开机自启动"; GroupDescription: "Additional options:"; Flags: unchecked
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#AppName}"; ValueData: """{app}\QtDesktopPet.exe"""; Flags: uninsdeletevalue; Tasks: startup
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"
|
Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"
|
||||||
Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}"
|
Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}"
|
||||||
@@ -79,4 +83,9 @@ begin
|
|||||||
DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet'));
|
DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet'));
|
||||||
DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet'));
|
DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet'));
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
if CurUninstallStep = usPostUninstall then
|
||||||
|
begin
|
||||||
|
RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', '{#AppName}');
|
||||||
|
end;
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "ConversationManager.h"
|
#include "ConversationManager.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
@@ -66,6 +67,12 @@ void ConversationManager::setMemoryHistoryMessageLimit(int maxMessages)
|
|||||||
pruneHistory();
|
pruneHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConversationManager::setConversationMetadata(const QString &provider, const QString &model)
|
||||||
|
{
|
||||||
|
m_currentProvider = provider.trimmed();
|
||||||
|
m_currentModel = model.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
|
bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
|
||||||
{
|
{
|
||||||
if (isBusy())
|
if (isBusy())
|
||||||
@@ -77,6 +84,32 @@ bool ConversationManager::setProvider(std::unique_ptr<LLMProvider> provider)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatRequest ConversationManager::buildRequestForUserMessage(const QString &message) const
|
||||||
|
{
|
||||||
|
const QString content = message.trimmed();
|
||||||
|
if (content.isEmpty())
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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::sendUserMessage(const QString &message, ResponseCallback callback)
|
void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback)
|
||||||
{
|
{
|
||||||
const QString content = message.trimmed();
|
const QString content = message.trimmed();
|
||||||
@@ -107,11 +140,13 @@ void ConversationManager::sendUserMessage(const QString &message, ResponseCallba
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatMessage userMessage{QStringLiteral("user"), content};
|
const QDateTime timestamp = QDateTime::currentDateTime();
|
||||||
|
const ChatMessage userMessage{QStringLiteral("user"), content, timestamp, m_currentProvider, m_currentModel};
|
||||||
m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
|
m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
|
||||||
if (response.success)
|
if (response.success)
|
||||||
{
|
{
|
||||||
appendExchange(userMessage, {QStringLiteral("assistant"), response.content});
|
ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model};
|
||||||
|
appendExchange(userMessage, assistantMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback)
|
if (callback)
|
||||||
@@ -151,14 +186,16 @@ void ConversationManager::sendUserMessageStreaming(const QString &message, Strea
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatMessage userMessage{QStringLiteral("user"), content};
|
const QDateTime timestamp = QDateTime::currentDateTime();
|
||||||
|
const ChatMessage userMessage{QStringLiteral("user"), content, timestamp, m_currentProvider, m_currentModel};
|
||||||
m_provider->sendStreamingChatRequest(
|
m_provider->sendStreamingChatRequest(
|
||||||
buildRequest(userMessage),
|
buildRequest(userMessage),
|
||||||
std::move(streamCallback),
|
std::move(streamCallback),
|
||||||
[this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
|
[this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable {
|
||||||
if (response.success)
|
if (response.success)
|
||||||
{
|
{
|
||||||
appendExchange(userMessage, {QStringLiteral("assistant"), response.content});
|
ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model};
|
||||||
|
appendExchange(userMessage, assistantMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback)
|
if (callback)
|
||||||
@@ -192,7 +229,7 @@ ChatRequest ConversationManager::buildRequest(const ChatMessage &userMessage) co
|
|||||||
ChatRequest request;
|
ChatRequest request;
|
||||||
if (!m_systemPrompt.trimmed().isEmpty())
|
if (!m_systemPrompt.trimmed().isEmpty())
|
||||||
{
|
{
|
||||||
request.messages.append({QStringLiteral("system"), m_systemPrompt});
|
request.messages.append(ChatMessage{QStringLiteral("system"), m_systemPrompt});
|
||||||
}
|
}
|
||||||
|
|
||||||
const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestContextMessages);
|
const int firstHistoryIndex = qMax(0, m_history.size() - m_maxRequestContextMessages);
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ public:
|
|||||||
void setHistory(const QVector<ChatMessage> &history);
|
void setHistory(const QVector<ChatMessage> &history);
|
||||||
void setRequestContextMessageLimit(int maxMessages);
|
void setRequestContextMessageLimit(int maxMessages);
|
||||||
void setMemoryHistoryMessageLimit(int maxMessages);
|
void setMemoryHistoryMessageLimit(int maxMessages);
|
||||||
|
void setConversationMetadata(const QString &provider, const QString &model);
|
||||||
bool setProvider(std::unique_ptr<LLMProvider> provider);
|
bool setProvider(std::unique_ptr<LLMProvider> provider);
|
||||||
|
ChatRequest buildRequestForUserMessage(const QString &message) const;
|
||||||
|
void appendExternalExchange(const QString &userMessage, const QString &assistantMessage);
|
||||||
void sendUserMessage(const QString &message, ResponseCallback callback);
|
void sendUserMessage(const QString &message, ResponseCallback callback);
|
||||||
void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
|
void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
|
||||||
void cancel();
|
void cancel();
|
||||||
@@ -38,6 +41,8 @@ private:
|
|||||||
std::unique_ptr<LLMProvider> m_provider;
|
std::unique_ptr<LLMProvider> m_provider;
|
||||||
QVector<ChatMessage> m_history;
|
QVector<ChatMessage> m_history;
|
||||||
QString m_systemPrompt;
|
QString m_systemPrompt;
|
||||||
|
QString m_currentProvider;
|
||||||
|
QString m_currentModel;
|
||||||
int m_maxRequestContextMessages = 12;
|
int m_maxRequestContextMessages = 12;
|
||||||
int m_maxStoredHistoryMessages = 200;
|
int m_maxStoredHistoryMessages = 200;
|
||||||
int m_prunedHistoryMessageCount = 0;
|
int m_prunedHistoryMessageCount = 0;
|
||||||
|
|||||||
@@ -24,14 +24,39 @@ QJsonObject objectFromMessage(const ChatMessage &message)
|
|||||||
QJsonObject object;
|
QJsonObject object;
|
||||||
object.insert(QStringLiteral("role"), message.role);
|
object.insert(QStringLiteral("role"), message.role);
|
||||||
object.insert(QStringLiteral("content"), message.content);
|
object.insert(QStringLiteral("content"), message.content);
|
||||||
|
if (message.timestamp.isValid())
|
||||||
|
{
|
||||||
|
object.insert(QStringLiteral("timestamp"), message.timestamp.toString(Qt::ISODate));
|
||||||
|
}
|
||||||
|
if (!message.provider.trimmed().isEmpty())
|
||||||
|
{
|
||||||
|
object.insert(QStringLiteral("provider"), message.provider.trimmed());
|
||||||
|
}
|
||||||
|
if (!message.model.trimmed().isEmpty())
|
||||||
|
{
|
||||||
|
object.insert(QStringLiteral("model"), message.model.trimmed());
|
||||||
|
}
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatMessage messageFromObject(const QJsonObject &object)
|
ChatMessage messageFromObject(const QJsonObject &object)
|
||||||
{
|
{
|
||||||
|
QDateTime timestamp = QDateTime::fromString(
|
||||||
|
object.value(QStringLiteral("timestamp")).toString(),
|
||||||
|
Qt::ISODate);
|
||||||
|
if (!timestamp.isValid())
|
||||||
|
{
|
||||||
|
timestamp = QDateTime::fromString(
|
||||||
|
object.value(QStringLiteral("createdAt")).toString(),
|
||||||
|
Qt::ISODate);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
object.value(QStringLiteral("role")).toString().trimmed(),
|
object.value(QStringLiteral("role")).toString().trimmed(),
|
||||||
object.value(QStringLiteral("content")).toString()
|
object.value(QStringLiteral("content")).toString(),
|
||||||
|
timestamp,
|
||||||
|
object.value(QStringLiteral("provider")).toString().trimmed(),
|
||||||
|
object.value(QStringLiteral("model")).toString().trimmed()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
|
||||||
@@ -7,6 +8,9 @@ struct ChatMessage
|
|||||||
{
|
{
|
||||||
QString role;
|
QString role;
|
||||||
QString content;
|
QString content;
|
||||||
|
QDateTime timestamp;
|
||||||
|
QString provider;
|
||||||
|
QString model;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ChatRequest
|
struct ChatRequest
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ QString userIntentTypeName(UserIntentType type)
|
|||||||
return QStringLiteral("Weather");
|
return QStringLiteral("Weather");
|
||||||
case UserIntentType::FileOperation:
|
case UserIntentType::FileOperation:
|
||||||
return QStringLiteral("FileOperation");
|
return QStringLiteral("FileOperation");
|
||||||
case UserIntentType::Search:
|
case UserIntentType::LaunchApp:
|
||||||
return QStringLiteral("Search");
|
return QStringLiteral("LaunchApp");
|
||||||
}
|
}
|
||||||
|
|
||||||
return QStringLiteral("Unknown");
|
return QStringLiteral("Unknown");
|
||||||
@@ -32,6 +32,21 @@ CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
|
|||||||
return {CommandDispatchAction::Reminder, intent, intent.text};
|
return {CommandDispatchAction::Reminder, intent, intent.text};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent.type == UserIntentType::Weather)
|
||||||
|
{
|
||||||
|
return {CommandDispatchAction::Weather, intent, intent.text};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.type == UserIntentType::FileOperation)
|
||||||
|
{
|
||||||
|
return {CommandDispatchAction::FileOperation, intent, intent.text};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.type == UserIntentType::LaunchApp)
|
||||||
|
{
|
||||||
|
return {CommandDispatchAction::LaunchApp, intent, intent.text};
|
||||||
|
}
|
||||||
|
|
||||||
return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)};
|
return {CommandDispatchAction::UnsupportedTool, intent, unsupportedToolMessage(intent.type)};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,16 +55,16 @@ QString CommandDispatcher::unsupportedToolMessage(UserIntentType type) const
|
|||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case UserIntentType::Reminder:
|
case UserIntentType::Reminder:
|
||||||
return QStringLiteral("提醒功能尚未接入,我现在还不能创建本地提醒。");
|
return QStringLiteral("提醒请求暂时无法处理,请稍后再试。");
|
||||||
case UserIntentType::Weather:
|
case UserIntentType::Weather:
|
||||||
return QStringLiteral("天气查询功能尚未接入,我现在还不能查询实时天气。");
|
return QStringLiteral("天气查询请求暂时无法处理,请稍后再试。");
|
||||||
case UserIntentType::FileOperation:
|
case UserIntentType::FileOperation:
|
||||||
return QStringLiteral("本地文件操作功能尚未接入。为避免风险,我现在不会读写或删除文件。");
|
return QStringLiteral("本地文件操作请求暂时无法处理,请稍后再试。");
|
||||||
case UserIntentType::Search:
|
case UserIntentType::LaunchApp:
|
||||||
return QStringLiteral("联网搜索功能尚未接入,我现在不会发起搜索请求。");
|
return QStringLiteral("应用启动请求暂时无法处理,请稍后再试。");
|
||||||
case UserIntentType::Chat:
|
case UserIntentType::Chat:
|
||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return QStringLiteral("该工具功能尚未接入。");
|
return QStringLiteral("该请求暂时无法处理,请稍后再试。");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ enum class CommandDispatchAction
|
|||||||
{
|
{
|
||||||
Chat,
|
Chat,
|
||||||
Reminder,
|
Reminder,
|
||||||
|
Weather,
|
||||||
|
FileOperation,
|
||||||
|
LaunchApp,
|
||||||
UnsupportedTool,
|
UnsupportedTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,20 +76,33 @@ bool isWeatherIntent(const QString &text)
|
|||||||
return containsAny(text, keywords);
|
return containsAny(text, keywords);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isSearchIntent(const QString &text)
|
bool isLaunchAppIntent(const QString &text)
|
||||||
{
|
{
|
||||||
|
static const QStringList blockedFileKeywords = {
|
||||||
|
QStringLiteral("打开文件"),
|
||||||
|
QStringLiteral("打开文件夹"),
|
||||||
|
QStringLiteral("打开目录"),
|
||||||
|
QStringLiteral("读取文件"),
|
||||||
|
QStringLiteral("复制文件"),
|
||||||
|
QStringLiteral("备份文件"),
|
||||||
|
QStringLiteral("重命名文件"),
|
||||||
|
QStringLiteral("保存到"),
|
||||||
|
QStringLiteral("截图"),
|
||||||
|
QStringLiteral("打包"),
|
||||||
|
QStringLiteral("压缩"),
|
||||||
|
QStringLiteral("脚本"),
|
||||||
|
QStringLiteral("命令"),
|
||||||
|
};
|
||||||
|
if (containsAny(text, blockedFileKeywords))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static const QStringList keywords = {
|
static const QStringList keywords = {
|
||||||
QStringLiteral("搜索"),
|
QStringLiteral("打开"),
|
||||||
QStringLiteral("搜一下"),
|
QStringLiteral("启动"),
|
||||||
QStringLiteral("查一下"),
|
QStringLiteral("运行"),
|
||||||
QStringLiteral("查找"),
|
QStringLiteral("唤起"),
|
||||||
QStringLiteral("联网"),
|
|
||||||
QStringLiteral("最新"),
|
|
||||||
QStringLiteral("官网"),
|
|
||||||
QStringLiteral("新闻"),
|
|
||||||
QStringLiteral("版本"),
|
|
||||||
QStringLiteral("文档"),
|
|
||||||
QStringLiteral("报错"),
|
|
||||||
};
|
};
|
||||||
return containsAny(text, keywords);
|
return containsAny(text, keywords);
|
||||||
}
|
}
|
||||||
@@ -114,9 +127,9 @@ UserIntent IntentRouter::route(const QString &text) const
|
|||||||
return {UserIntentType::Weather, trimmedText};
|
return {UserIntentType::Weather, trimmedText};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSearchIntent(trimmedText))
|
if (isLaunchAppIntent(trimmedText))
|
||||||
{
|
{
|
||||||
return {UserIntentType::Search, trimmedText};
|
return {UserIntentType::LaunchApp, trimmedText};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {UserIntentType::Chat, trimmedText};
|
return {UserIntentType::Chat, trimmedText};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ enum class UserIntentType
|
|||||||
Reminder,
|
Reminder,
|
||||||
Weather,
|
Weather,
|
||||||
FileOperation,
|
FileOperation,
|
||||||
Search,
|
LaunchApp,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UserIntent
|
struct UserIntent
|
||||||
|
|||||||
@@ -629,6 +629,70 @@ bool CharacterPackageRepository::importPackageDirectory(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::exportPackageDirectory(
|
||||||
|
const QString &characterId,
|
||||||
|
const QString &targetParentDirectoryPath,
|
||||||
|
bool overwrite,
|
||||||
|
QString *exportedPath,
|
||||||
|
QString *errorMessage)
|
||||||
|
{
|
||||||
|
const QString trimmed = characterId.trimmed();
|
||||||
|
if (!isValidCharacterId(trimmed))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色 id 无效。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString sourcePath = packagePath(trimmed);
|
||||||
|
if (sourcePath.isEmpty() || !QFileInfo::exists(QDir(sourcePath).filePath(QStringLiteral("character.json"))))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色包不存在或不可导出。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString validationId;
|
||||||
|
QString displayName;
|
||||||
|
if (!validatePackageDirectory(sourcePath, &validationId, &displayName, errorMessage))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo targetParentInfo(QDir::cleanPath(targetParentDirectoryPath));
|
||||||
|
if (!targetParentInfo.exists() || !targetParentInfo.isDir())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("导出目标目录不存在。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString targetPath = QDir(targetParentInfo.absoluteFilePath()).filePath(trimmed);
|
||||||
|
const QString cleanedSourcePath = QDir::cleanPath(sourcePath);
|
||||||
|
const QString cleanedTargetPath = QDir::cleanPath(targetPath);
|
||||||
|
if (cleanedSourcePath == cleanedTargetPath)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("导出目标不能与源角色目录相同。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir targetRoot(targetParentInfo.absoluteFilePath());
|
||||||
|
const QString exportStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz"));
|
||||||
|
const QString preparedPath = targetRoot.filePath(QStringLiteral(".exporting-") + trimmed + QStringLiteral("-") + exportStamp);
|
||||||
|
QDir(preparedPath).removeRecursively();
|
||||||
|
|
||||||
|
if (!copyDirectoryRecursively(cleanedSourcePath, preparedPath, errorMessage))
|
||||||
|
{
|
||||||
|
QDir(preparedPath).removeRecursively();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replaceDirectoryWithPreparedImport(preparedPath, cleanedTargetPath, overwrite, errorMessage))
|
||||||
|
{
|
||||||
|
QDir(preparedPath).removeRecursively();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportedPath != nullptr)
|
||||||
|
{
|
||||||
|
*exportedPath = cleanedTargetPath;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage)
|
bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage)
|
||||||
{
|
{
|
||||||
const QString trimmed = characterId.trimmed();
|
const QString trimmed = characterId.trimmed();
|
||||||
|
|||||||
@@ -52,5 +52,11 @@ public:
|
|||||||
bool overwrite,
|
bool overwrite,
|
||||||
QString *importedCharacterId = nullptr,
|
QString *importedCharacterId = nullptr,
|
||||||
QString *errorMessage = nullptr);
|
QString *errorMessage = nullptr);
|
||||||
|
static bool exportPackageDirectory(
|
||||||
|
const QString &characterId,
|
||||||
|
const QString &targetParentDirectoryPath,
|
||||||
|
bool overwrite,
|
||||||
|
QString *exportedPath = nullptr,
|
||||||
|
QString *errorMessage = nullptr);
|
||||||
static bool deleteUserPackage(const QString &characterId, QString *errorMessage = nullptr);
|
static bool deleteUserPackage(const QString &characterId, QString *errorMessage = nullptr);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct AppConfig
|
|||||||
QPoint windowPosition = QPoint(100, 100);
|
QPoint windowPosition = QPoint(100, 100);
|
||||||
bool hasWindowPosition = false;
|
bool hasWindowPosition = false;
|
||||||
bool alwaysOnTop = true;
|
bool alwaysOnTop = true;
|
||||||
|
bool launchAtStartup = false;
|
||||||
double scale = 1.0;
|
double scale = 1.0;
|
||||||
QString performanceMode = QStringLiteral("standard");
|
QString performanceMode = QStringLiteral("standard");
|
||||||
bool pauseWhenHidden = true;
|
bool pauseWhenHidden = true;
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ QJsonObject windowObjectFromConfig(const AppConfig &config)
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QJsonObject systemObjectFromConfig(const AppConfig &config)
|
||||||
|
{
|
||||||
|
QJsonObject system;
|
||||||
|
system.insert(QStringLiteral("launchAtStartup"), config.launchAtStartup);
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject performanceObjectFromConfig(const AppConfig &config)
|
QJsonObject performanceObjectFromConfig(const AppConfig &config)
|
||||||
{
|
{
|
||||||
QJsonObject performance;
|
QJsonObject performance;
|
||||||
@@ -210,6 +217,12 @@ AppConfig ConfigManager::loadAppConfig() const
|
|||||||
config.scale = window.value(QStringLiteral("scale")).toDouble(config.scale);
|
config.scale = window.value(QStringLiteral("scale")).toDouble(config.scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QJsonObject system = root.value(QStringLiteral("system")).toObject();
|
||||||
|
if (system.contains(QStringLiteral("launchAtStartup")))
|
||||||
|
{
|
||||||
|
config.launchAtStartup = system.value(QStringLiteral("launchAtStartup")).toBool(config.launchAtStartup);
|
||||||
|
}
|
||||||
|
|
||||||
const QJsonObject performance = root.value(QStringLiteral("performance")).toObject();
|
const QJsonObject performance = root.value(QStringLiteral("performance")).toObject();
|
||||||
if (performance.contains(QStringLiteral("mode")))
|
if (performance.contains(QStringLiteral("mode")))
|
||||||
{
|
{
|
||||||
@@ -373,6 +386,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
|
|||||||
|
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
|
root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
|
||||||
|
root.insert(QStringLiteral("system"), systemObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#include "FileBackupManager.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool setError(QString *errorMessage, const QString &message)
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = message;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileBackupManager::createBackup(const QString &sourceFilePath, QString *backupPath, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
if (!m_sandbox.validateReadableFile(sourceFilePath, errorMessage))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo sourceInfo(sourceFilePath);
|
||||||
|
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss"));
|
||||||
|
const QString suffix = sourceInfo.suffix().trimmed();
|
||||||
|
const QString backupName = suffix.isEmpty()
|
||||||
|
? QStringLiteral("%1.backup.%2").arg(sourceInfo.completeBaseName(), timestamp)
|
||||||
|
: QStringLiteral("%1.backup.%2.%3").arg(sourceInfo.completeBaseName(), timestamp, suffix);
|
||||||
|
const QString targetPath = QDir(sourceInfo.absolutePath()).filePath(backupName);
|
||||||
|
if (!m_sandbox.validateNewFileTarget(targetPath, errorMessage))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QFile::copy(sourceInfo.absoluteFilePath(), targetPath))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("创建备份文件失败。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupPath != nullptr)
|
||||||
|
{
|
||||||
|
*backupPath = targetPath;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "FileSandbox.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class FileBackupManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool createBackup(const QString &sourceFilePath, QString *backupPath = nullptr, QString *errorMessage = nullptr) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
FileSandbox m_sandbox;
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
#include "FileOperationManager.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr qint64 MaxReadBytes = 64 * 1024;
|
||||||
|
constexpr int MaxDirectoryEntries = 200;
|
||||||
|
|
||||||
|
FileOperationResult failedResult(const QString &message)
|
||||||
|
{
|
||||||
|
FileOperationResult result;
|
||||||
|
result.success = false;
|
||||||
|
result.errorMessage = message;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString fileDisplayName(const QString &path)
|
||||||
|
{
|
||||||
|
const QFileInfo info(path);
|
||||||
|
return info.fileName().isEmpty() ? info.absoluteFilePath() : info.fileName();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString copyTargetPath(const QString &sourceFilePath, const QString &targetDirectoryPath)
|
||||||
|
{
|
||||||
|
return QDir(targetDirectoryPath).filePath(QFileInfo(sourceFilePath).fileName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationPlan FileOperationManager::readTextFilePlan(const QString &filePath) const
|
||||||
|
{
|
||||||
|
FileOperationPlan plan;
|
||||||
|
plan.type = FileOperationType::ReadTextFile;
|
||||||
|
plan.title = QStringLiteral("读取文本文件");
|
||||||
|
plan.sourcePath = QFileInfo(filePath).absoluteFilePath();
|
||||||
|
plan.description = QStringLiteral("读取文本文件:%1").arg(plan.sourcePath);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationPlan FileOperationManager::listDirectoryPlan(const QString &directoryPath) const
|
||||||
|
{
|
||||||
|
FileOperationPlan plan;
|
||||||
|
plan.type = FileOperationType::ListDirectory;
|
||||||
|
plan.title = QStringLiteral("列出文件夹");
|
||||||
|
plan.sourcePath = QFileInfo(directoryPath).absoluteFilePath();
|
||||||
|
plan.description = QStringLiteral("列出文件夹内容:%1").arg(plan.sourcePath);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationPlan FileOperationManager::copyFilePlan(const QString &sourceFilePath, const QString &targetDirectoryPath) const
|
||||||
|
{
|
||||||
|
FileOperationPlan plan;
|
||||||
|
plan.type = FileOperationType::CopyFile;
|
||||||
|
plan.title = QStringLiteral("复制文件");
|
||||||
|
plan.sourcePath = QFileInfo(sourceFilePath).absoluteFilePath();
|
||||||
|
plan.targetPath = copyTargetPath(plan.sourcePath, QFileInfo(targetDirectoryPath).absoluteFilePath());
|
||||||
|
plan.description = QStringLiteral("复制文件:\n%1\n到:\n%2").arg(plan.sourcePath, plan.targetPath);
|
||||||
|
plan.requiresConfirmation = true;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationPlan FileOperationManager::backupFilePlan(const QString &sourceFilePath) const
|
||||||
|
{
|
||||||
|
FileOperationPlan plan;
|
||||||
|
plan.type = FileOperationType::BackupFile;
|
||||||
|
plan.title = QStringLiteral("创建备份");
|
||||||
|
plan.sourcePath = QFileInfo(sourceFilePath).absoluteFilePath();
|
||||||
|
plan.description = QStringLiteral("为文件创建同目录备份:\n%1").arg(plan.sourcePath);
|
||||||
|
plan.requiresConfirmation = true;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationPlan FileOperationManager::renameFilePlan(const QString &sourceFilePath, const QString &newFileName) const
|
||||||
|
{
|
||||||
|
FileOperationPlan plan;
|
||||||
|
plan.type = FileOperationType::RenameFile;
|
||||||
|
plan.title = QStringLiteral("重命名文件");
|
||||||
|
plan.sourcePath = QFileInfo(sourceFilePath).absoluteFilePath();
|
||||||
|
plan.targetPath = QDir(QFileInfo(plan.sourcePath).absolutePath()).filePath(newFileName.trimmed());
|
||||||
|
plan.description = QStringLiteral("重命名文件:\n%1\n为:\n%2").arg(plan.sourcePath, plan.targetPath);
|
||||||
|
plan.requiresConfirmation = true;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult FileOperationManager::executeReadTextFile(const FileOperationPlan &plan) const
|
||||||
|
{
|
||||||
|
QString errorMessage;
|
||||||
|
if (!m_sandbox.validateReadableTextFile(plan.sourcePath, &errorMessage))
|
||||||
|
{
|
||||||
|
return failedResult(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(plan.sourcePath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
|
{
|
||||||
|
return failedResult(QStringLiteral("无法读取文本文件。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray content = file.read(MaxReadBytes + 1);
|
||||||
|
QString text = QString::fromUtf8(content.left(MaxReadBytes));
|
||||||
|
if (content.size() > MaxReadBytes)
|
||||||
|
{
|
||||||
|
text += QStringLiteral("\n\n[文件较大,仅显示前 64KB。]");
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.outputText = text;
|
||||||
|
result.message = QStringLiteral("已读取:%1").arg(fileDisplayName(plan.sourcePath));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult FileOperationManager::executeListDirectory(const FileOperationPlan &plan) const
|
||||||
|
{
|
||||||
|
QString errorMessage;
|
||||||
|
if (!m_sandbox.validateReadableDirectory(plan.sourcePath, &errorMessage))
|
||||||
|
{
|
||||||
|
return failedResult(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir directory(plan.sourcePath);
|
||||||
|
const QFileInfoList entries = directory.entryInfoList(
|
||||||
|
QDir::NoDotAndDotDot | QDir::AllEntries,
|
||||||
|
QDir::DirsFirst | QDir::Name);
|
||||||
|
|
||||||
|
QStringList lines;
|
||||||
|
lines.append(QStringLiteral("文件夹内容:%1").arg(directory.absolutePath()));
|
||||||
|
const int count = qMin(entries.size(), MaxDirectoryEntries);
|
||||||
|
for (int index = 0; index < count; ++index)
|
||||||
|
{
|
||||||
|
const QFileInfo &entry = entries.at(index);
|
||||||
|
lines.append(QStringLiteral("%1 %2").arg(entry.isDir() ? QStringLiteral("[目录]") : QStringLiteral("[文件]"), entry.fileName()));
|
||||||
|
}
|
||||||
|
if (entries.size() > MaxDirectoryEntries)
|
||||||
|
{
|
||||||
|
lines.append(QStringLiteral("[仅显示前 %1 项,共 %2 项]").arg(MaxDirectoryEntries).arg(entries.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.outputText = lines.join(QLatin1Char('\n'));
|
||||||
|
result.message = QStringLiteral("已列出文件夹。");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult FileOperationManager::executeCopyFile(const FileOperationPlan &plan) const
|
||||||
|
{
|
||||||
|
QString errorMessage;
|
||||||
|
if (!m_sandbox.validateReadableFile(plan.sourcePath, &errorMessage)
|
||||||
|
|| !m_sandbox.validateNewFileTarget(plan.targetPath, &errorMessage))
|
||||||
|
{
|
||||||
|
return failedResult(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QFile::copy(plan.sourcePath, plan.targetPath))
|
||||||
|
{
|
||||||
|
return failedResult(QStringLiteral("复制文件失败。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.targetPath = plan.targetPath;
|
||||||
|
result.message = QStringLiteral("已复制到:%1").arg(plan.targetPath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult FileOperationManager::executeBackupFile(const FileOperationPlan &plan) const
|
||||||
|
{
|
||||||
|
QString backupPath;
|
||||||
|
QString errorMessage;
|
||||||
|
if (!m_backupManager.createBackup(plan.sourcePath, &backupPath, &errorMessage))
|
||||||
|
{
|
||||||
|
return failedResult(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.targetPath = backupPath;
|
||||||
|
result.message = QStringLiteral("已创建备份:%1").arg(backupPath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult FileOperationManager::executeRenameFile(const FileOperationPlan &plan) const
|
||||||
|
{
|
||||||
|
QString errorMessage;
|
||||||
|
if (!m_sandbox.validateReadableFile(plan.sourcePath, &errorMessage)
|
||||||
|
|| !m_sandbox.validateSafeFileName(QFileInfo(plan.targetPath).fileName(), &errorMessage)
|
||||||
|
|| !m_sandbox.validateNewFileTarget(plan.targetPath, &errorMessage))
|
||||||
|
{
|
||||||
|
return failedResult(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(plan.sourcePath);
|
||||||
|
if (!file.rename(plan.targetPath))
|
||||||
|
{
|
||||||
|
return failedResult(QStringLiteral("重命名文件失败。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOperationResult result;
|
||||||
|
result.success = true;
|
||||||
|
result.targetPath = plan.targetPath;
|
||||||
|
result.message = QStringLiteral("已重命名为:%1").arg(plan.targetPath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "FileBackupManager.h"
|
||||||
|
#include "FileOperationTypes.h"
|
||||||
|
#include "FileSandbox.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class FileOperationManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FileOperationPlan readTextFilePlan(const QString &filePath) const;
|
||||||
|
FileOperationPlan listDirectoryPlan(const QString &directoryPath) const;
|
||||||
|
FileOperationPlan copyFilePlan(const QString &sourceFilePath, const QString &targetDirectoryPath) const;
|
||||||
|
FileOperationPlan backupFilePlan(const QString &sourceFilePath) const;
|
||||||
|
FileOperationPlan renameFilePlan(const QString &sourceFilePath, const QString &newFileName) const;
|
||||||
|
|
||||||
|
FileOperationResult executeReadTextFile(const FileOperationPlan &plan) const;
|
||||||
|
FileOperationResult executeListDirectory(const FileOperationPlan &plan) const;
|
||||||
|
FileOperationResult executeCopyFile(const FileOperationPlan &plan) const;
|
||||||
|
FileOperationResult executeBackupFile(const FileOperationPlan &plan) const;
|
||||||
|
FileOperationResult executeRenameFile(const FileOperationPlan &plan) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
FileSandbox m_sandbox;
|
||||||
|
FileBackupManager m_backupManager;
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
enum class FileOperationType
|
||||||
|
{
|
||||||
|
ReadTextFile,
|
||||||
|
ListDirectory,
|
||||||
|
CopyFile,
|
||||||
|
BackupFile,
|
||||||
|
RenameFile,
|
||||||
|
ZipPackage,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FileOperationPlan
|
||||||
|
{
|
||||||
|
FileOperationType type = FileOperationType::ReadTextFile;
|
||||||
|
QString title;
|
||||||
|
QString description;
|
||||||
|
QString sourcePath;
|
||||||
|
QString targetPath;
|
||||||
|
QStringList warnings;
|
||||||
|
bool requiresConfirmation = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FileOperationResult
|
||||||
|
{
|
||||||
|
bool success = false;
|
||||||
|
QString message;
|
||||||
|
QString errorMessage;
|
||||||
|
QString outputText;
|
||||||
|
QString targetPath;
|
||||||
|
};
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#include "FileSandbox.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool setError(QString *errorMessage, const QString &message)
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = message;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString cleanedAbsolutePath(const QString &path)
|
||||||
|
{
|
||||||
|
QString cleaned = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
||||||
|
cleaned.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isWindowsSystemPath(const QString &path)
|
||||||
|
{
|
||||||
|
const QString cleaned = cleanedAbsolutePath(path).toUpper();
|
||||||
|
QStringList deniedPrefixes = {
|
||||||
|
QStringLiteral("C:/WINDOWS"),
|
||||||
|
QStringLiteral("C:/PROGRAM FILES"),
|
||||||
|
QStringLiteral("C:/PROGRAM FILES (X86)"),
|
||||||
|
QStringLiteral("C:/PROGRAMDATA"),
|
||||||
|
QStringLiteral("C:/SYSTEM VOLUME INFORMATION"),
|
||||||
|
QStringLiteral("C:/$RECYCLE.BIN")
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto addEnvironmentPath = [&deniedPrefixes](const char *name) {
|
||||||
|
const QString value = qEnvironmentVariable(name).trimmed();
|
||||||
|
if (!value.isEmpty())
|
||||||
|
{
|
||||||
|
deniedPrefixes.append(cleanedAbsolutePath(value).toUpper());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addEnvironmentPath("WINDIR");
|
||||||
|
addEnvironmentPath("SystemRoot");
|
||||||
|
addEnvironmentPath("ProgramFiles");
|
||||||
|
addEnvironmentPath("ProgramFiles(x86)");
|
||||||
|
addEnvironmentPath("ProgramData");
|
||||||
|
|
||||||
|
for (const QString &prefix : deniedPrefixes)
|
||||||
|
{
|
||||||
|
if (cleaned == prefix || cleaned.startsWith(prefix + QLatin1Char('/')))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasSymlinkSegment(const QString &path)
|
||||||
|
{
|
||||||
|
QFileInfo info(path);
|
||||||
|
if (info.isSymLink())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir directory(info.isDir() ? info.absoluteFilePath() : info.absolutePath());
|
||||||
|
while (directory.exists())
|
||||||
|
{
|
||||||
|
const QFileInfo directoryInfo(directory.absolutePath());
|
||||||
|
if (directoryInfo.isSymLink())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!directory.cdUp())
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSupportedTextSuffix(const QString &suffix)
|
||||||
|
{
|
||||||
|
static const QStringList supported = {
|
||||||
|
QStringLiteral("txt"),
|
||||||
|
QStringLiteral("md"),
|
||||||
|
QStringLiteral("markdown"),
|
||||||
|
QStringLiteral("log"),
|
||||||
|
QStringLiteral("json"),
|
||||||
|
QStringLiteral("csv"),
|
||||||
|
QStringLiteral("ini"),
|
||||||
|
QStringLiteral("xml"),
|
||||||
|
QStringLiteral("yaml"),
|
||||||
|
QStringLiteral("yml")
|
||||||
|
};
|
||||||
|
return supported.contains(suffix.trimmed().toLower());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSandbox::validateReadableFile(const QString &filePath, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QFileInfo info(filePath);
|
||||||
|
if (!info.exists() || !info.isFile())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("请选择一个存在的文件。"));
|
||||||
|
}
|
||||||
|
if (!info.isReadable())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("文件不可读取。"));
|
||||||
|
}
|
||||||
|
if (hasSymlinkSegment(info.absoluteFilePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("不允许操作符号链接或符号链接目录内的文件。"));
|
||||||
|
}
|
||||||
|
if (isWindowsSystemPath(info.absoluteFilePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("不允许访问系统目录内的文件。"));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSandbox::validateReadableTextFile(const QString &filePath, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
if (!validateReadableFile(filePath, errorMessage))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo info(filePath);
|
||||||
|
if (!isSupportedTextSuffix(info.suffix()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("只允许读取常见文本文件:txt、md、log、json、csv、ini、xml、yaml。"));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSandbox::validateReadableDirectory(const QString &directoryPath, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QFileInfo info(directoryPath);
|
||||||
|
if (!info.exists() || !info.isDir())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("请选择一个存在的文件夹。"));
|
||||||
|
}
|
||||||
|
if (!info.isReadable())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("文件夹不可读取。"));
|
||||||
|
}
|
||||||
|
if (hasSymlinkSegment(info.absoluteFilePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("不允许操作符号链接或符号链接目录。"));
|
||||||
|
}
|
||||||
|
if (isWindowsSystemPath(info.absoluteFilePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("不允许访问系统目录。"));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSandbox::validateWritableDirectory(const QString &directoryPath, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QFileInfo info(directoryPath);
|
||||||
|
if (!info.exists() || !info.isDir())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("请选择一个存在的目标文件夹。"));
|
||||||
|
}
|
||||||
|
if (!info.isWritable())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("目标文件夹不可写。"));
|
||||||
|
}
|
||||||
|
if (hasSymlinkSegment(info.absoluteFilePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("不允许写入符号链接目录。"));
|
||||||
|
}
|
||||||
|
if (isWindowsSystemPath(info.absoluteFilePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("不允许写入系统目录。"));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSandbox::validateNewFileTarget(const QString &filePath, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QFileInfo info(filePath);
|
||||||
|
if (info.exists())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("目标文件已存在,本版不会覆盖文件。"));
|
||||||
|
}
|
||||||
|
return validateWritableDirectory(info.absolutePath(), errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSandbox::validateSafeFileName(const QString &fileName, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QString trimmed = fileName.trimmed();
|
||||||
|
if (trimmed.isEmpty() || trimmed == QStringLiteral(".") || trimmed == QStringLiteral(".."))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("文件名不能为空。"));
|
||||||
|
}
|
||||||
|
if (trimmed.contains(QLatin1Char('/')) || trimmed.contains(QLatin1Char('\\')) || trimmed.contains(QLatin1Char(':')))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("文件名不能包含路径分隔符或冒号。"));
|
||||||
|
}
|
||||||
|
if (trimmed.contains(QLatin1Char('*')) || trimmed.contains(QLatin1Char('?')) || trimmed.contains(QLatin1Char('"'))
|
||||||
|
|| trimmed.contains(QLatin1Char('<')) || trimmed.contains(QLatin1Char('>')) || trimmed.contains(QLatin1Char('|')))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("文件名包含 Windows 不允许的字符。"));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class FileSandbox
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool validateReadableFile(const QString &filePath, QString *errorMessage = nullptr) const;
|
||||||
|
bool validateReadableTextFile(const QString &filePath, QString *errorMessage = nullptr) const;
|
||||||
|
bool validateReadableDirectory(const QString &directoryPath, QString *errorMessage = nullptr) const;
|
||||||
|
bool validateWritableDirectory(const QString &directoryPath, QString *errorMessage = nullptr) const;
|
||||||
|
bool validateNewFileTarget(const QString &filePath, QString *errorMessage = nullptr) const;
|
||||||
|
bool validateSafeFileName(const QString &fileName, QString *errorMessage = nullptr) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#include "AppDiscovery.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QProcessEnvironment>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
QString normalizedName(QString value)
|
||||||
|
{
|
||||||
|
value = value.trimmed().toLower();
|
||||||
|
value.remove(QChar::Space);
|
||||||
|
value.remove(QStringLiteral(".exe"));
|
||||||
|
value.remove(QStringLiteral(".lnk"));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList startMenuRoots()
|
||||||
|
{
|
||||||
|
QStringList roots;
|
||||||
|
const QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
||||||
|
const QString appData = environment.value(QStringLiteral("APPDATA"));
|
||||||
|
const QString programData = environment.value(QStringLiteral("PROGRAMDATA"));
|
||||||
|
if (!appData.isEmpty())
|
||||||
|
{
|
||||||
|
roots.append(QDir(appData).filePath(QStringLiteral("Microsoft/Windows/Start Menu/Programs")));
|
||||||
|
}
|
||||||
|
if (!programData.isEmpty())
|
||||||
|
{
|
||||||
|
roots.append(QDir(programData).filePath(QStringLiteral("Microsoft/Windows/Start Menu/Programs")));
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredApp appFromShortcut(const QString &shortcutPath)
|
||||||
|
{
|
||||||
|
RegisteredApp app;
|
||||||
|
const QFileInfo info(shortcutPath);
|
||||||
|
app.id = info.completeBaseName().toLower();
|
||||||
|
app.displayName = info.completeBaseName();
|
||||||
|
app.aliases = {app.displayName};
|
||||||
|
app.shortcutPath = info.absoluteFilePath();
|
||||||
|
app.workingDirectory = info.absolutePath();
|
||||||
|
app.userDefined = false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredApp appFromExecutable(const QString &executablePath)
|
||||||
|
{
|
||||||
|
RegisteredApp app;
|
||||||
|
const QFileInfo info(executablePath);
|
||||||
|
app.id = info.completeBaseName().toLower();
|
||||||
|
app.displayName = info.completeBaseName();
|
||||||
|
app.aliases = {app.displayName, info.fileName()};
|
||||||
|
app.executablePath = info.absoluteFilePath();
|
||||||
|
app.workingDirectory = info.absolutePath();
|
||||||
|
app.userDefined = false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppDiscovery::findRegisteredApp(const QString &requestedName, const AppLaunchConfig &config, RegisteredApp *app) const
|
||||||
|
{
|
||||||
|
for (const RegisteredApp ®isteredApp : config.apps)
|
||||||
|
{
|
||||||
|
if (matchesName(requestedName, registeredApp))
|
||||||
|
{
|
||||||
|
if (app != nullptr)
|
||||||
|
{
|
||||||
|
*app = registeredApp;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppDiscovery::findStartMenuShortcut(const QString &requestedName, RegisteredApp *app) const
|
||||||
|
{
|
||||||
|
const QString requested = normalizedName(requestedName);
|
||||||
|
if (requested.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QString &root : startMenuRoots())
|
||||||
|
{
|
||||||
|
if (!QDir(root).exists())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDirIterator iterator(root, QStringList{QStringLiteral("*.lnk")}, QDir::Files, QDirIterator::Subdirectories);
|
||||||
|
while (iterator.hasNext())
|
||||||
|
{
|
||||||
|
const QString path = iterator.next();
|
||||||
|
const QFileInfo info(path);
|
||||||
|
if (normalizedName(info.completeBaseName()) == requested)
|
||||||
|
{
|
||||||
|
if (app != nullptr)
|
||||||
|
{
|
||||||
|
*app = appFromShortcut(path);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppDiscovery::findAppPathRegistryEntry(const QString &requestedName, RegisteredApp *app) const
|
||||||
|
{
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
const QString requested = normalizedName(requestedName);
|
||||||
|
if (requested.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList roots = {
|
||||||
|
QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths"),
|
||||||
|
QStringLiteral("HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths"),
|
||||||
|
};
|
||||||
|
for (const QString &root : roots)
|
||||||
|
{
|
||||||
|
QSettings settings(root, QSettings::NativeFormat);
|
||||||
|
for (const QString &group : settings.childGroups())
|
||||||
|
{
|
||||||
|
const QFileInfo groupInfo(group);
|
||||||
|
if (normalizedName(groupInfo.completeBaseName()) != requested && normalizedName(group) != requested)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.beginGroup(group);
|
||||||
|
QString executablePath = settings.value(QStringLiteral(".")).toString().trimmed();
|
||||||
|
if (executablePath.isEmpty())
|
||||||
|
{
|
||||||
|
executablePath = settings.value(QStringLiteral("")).toString().trimmed();
|
||||||
|
}
|
||||||
|
settings.endGroup();
|
||||||
|
if (executablePath.isEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app != nullptr)
|
||||||
|
{
|
||||||
|
*app = appFromExecutable(executablePath);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Q_UNUSED(requestedName);
|
||||||
|
Q_UNUSED(app);
|
||||||
|
#endif
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppDiscovery::matchesName(const QString &requestedName, const RegisteredApp &app) const
|
||||||
|
{
|
||||||
|
const QString requested = normalizedName(requestedName);
|
||||||
|
if (requested.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName(app.displayName) == requested)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QString &alias : app.aliases)
|
||||||
|
{
|
||||||
|
if (normalizedName(alias) == requested)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo executableInfo(app.executablePath);
|
||||||
|
if (!app.executablePath.isEmpty() && normalizedName(executableInfo.completeBaseName()) == requested)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo shortcutInfo(app.shortcutPath);
|
||||||
|
return !app.shortcutPath.isEmpty() && normalizedName(shortcutInfo.completeBaseName()) == requested;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "AppLaunchTypes.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class AppDiscovery
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool findRegisteredApp(const QString &requestedName, const AppLaunchConfig &config, RegisteredApp *app) const;
|
||||||
|
bool findStartMenuShortcut(const QString &requestedName, RegisteredApp *app) const;
|
||||||
|
bool findAppPathRegistryEntry(const QString &requestedName, RegisteredApp *app) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool matchesName(const QString &requestedName, const RegisteredApp &app) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
#include "AppLaunchManager.h"
|
||||||
|
|
||||||
|
#include "../util/Logger.h"
|
||||||
|
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool containsAny(const QString &text, const QStringList &keywords)
|
||||||
|
{
|
||||||
|
for (const QString &keyword : keywords)
|
||||||
|
{
|
||||||
|
if (text.contains(keyword, Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString sanitizedId(QString value)
|
||||||
|
{
|
||||||
|
value = value.trimmed().toLower();
|
||||||
|
value.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_"));
|
||||||
|
if (value.isEmpty())
|
||||||
|
{
|
||||||
|
value = QStringLiteral("app");
|
||||||
|
}
|
||||||
|
return value.left(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedExecutablePath(const QString &path)
|
||||||
|
{
|
||||||
|
const QFileInfo info(path.trimmed());
|
||||||
|
return info.exists() ? info.absoluteFilePath() : path.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString fileSuffix(const QString &path)
|
||||||
|
{
|
||||||
|
return QFileInfo(path.trimmed()).suffix().toLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppLaunchManager::isLaunchIntentText(const QString &text) const
|
||||||
|
{
|
||||||
|
const QString normalized = text.trimmed();
|
||||||
|
if (normalized.isEmpty() || isBlockedFileOperationText(normalized))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return containsAny(normalized, {
|
||||||
|
QStringLiteral("打开"),
|
||||||
|
QStringLiteral("启动"),
|
||||||
|
QStringLiteral("运行"),
|
||||||
|
QStringLiteral("唤起"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AppLaunchManager::requestedAppNameFromText(const QString &text) const
|
||||||
|
{
|
||||||
|
QString requested = text.trimmed();
|
||||||
|
requested.remove(QRegularExpression(QStringLiteral("^(请|麻烦|帮我|帮忙|给我)")));
|
||||||
|
requested.remove(QRegularExpression(QStringLiteral("(打开一下|启动一下|运行一下|打开|启动|运行|唤起)")));
|
||||||
|
requested.remove(QRegularExpression(QStringLiteral("(这个|一下|应用|软件|程序)$")));
|
||||||
|
requested = requested.simplified();
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchPlan AppLaunchManager::resolveLaunchPlan(const QString &text, const AppLaunchConfig &config) const
|
||||||
|
{
|
||||||
|
AppLaunchPlan plan;
|
||||||
|
plan.requestedName = requestedAppNameFromText(text);
|
||||||
|
if (!config.enabled)
|
||||||
|
{
|
||||||
|
plan.errorMessage = QStringLiteral("应用启动未启用,请先在设置页开启。");
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.requestedName.isEmpty() || !isLaunchIntentText(text))
|
||||||
|
{
|
||||||
|
plan.errorMessage = QStringLiteral("没有识别到要启动的应用名称。");
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredApp app;
|
||||||
|
if (m_discovery.findRegisteredApp(plan.requestedName, config, &app))
|
||||||
|
{
|
||||||
|
return planFromRegisteredApp(plan.requestedName, app, QStringLiteral("用户登记"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_discovery.findStartMenuShortcut(plan.requestedName, &app))
|
||||||
|
{
|
||||||
|
return planFromRegisteredApp(plan.requestedName, app, QStringLiteral("开始菜单"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_discovery.findAppPathRegistryEntry(plan.requestedName, &app))
|
||||||
|
{
|
||||||
|
return planFromRegisteredApp(plan.requestedName, app, QStringLiteral("App Paths 注册表"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.allowManualExeSelection)
|
||||||
|
{
|
||||||
|
plan.needsManualSelection = true;
|
||||||
|
plan.errorMessage = QStringLiteral("没有找到匹配的应用,需要手动选择 exe。");
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.errorMessage = QStringLiteral("没有找到匹配的应用。可在设置页登记应用别名,或开启未知应用手选 exe。");
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchPlan AppLaunchManager::manualSelectionPlan(const QString &text, const QString &executablePath) const
|
||||||
|
{
|
||||||
|
AppLaunchPlan plan;
|
||||||
|
plan.requestedName = requestedAppNameFromText(text);
|
||||||
|
const QFileInfo info(executablePath);
|
||||||
|
plan.displayName = info.completeBaseName();
|
||||||
|
plan.executablePath = normalizedExecutablePath(executablePath);
|
||||||
|
plan.workingDirectory = info.absolutePath();
|
||||||
|
plan.matchSource = QStringLiteral("手动选择");
|
||||||
|
plan.canRemember = true;
|
||||||
|
QString errorMessage;
|
||||||
|
plan.success = validateLaunchPlan(plan, &errorMessage);
|
||||||
|
plan.errorMessage = errorMessage;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchResult AppLaunchManager::executeLaunchPlan(const AppLaunchPlan &plan) const
|
||||||
|
{
|
||||||
|
QString errorMessage;
|
||||||
|
if (!validateLaunchPlan(plan, &errorMessage))
|
||||||
|
{
|
||||||
|
return {false, {}, errorMessage};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool started = false;
|
||||||
|
if (!plan.executablePath.isEmpty())
|
||||||
|
{
|
||||||
|
started = QProcess::startDetached(plan.executablePath, QStringList(), plan.workingDirectory);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
started = QDesktopServices::openUrl(QUrl::fromLocalFile(plan.shortcutPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!started)
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Failed to launch app: %1").arg(plan.displayName));
|
||||||
|
return {false, {}, QStringLiteral("启动应用失败。")};
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::info(QStringLiteral("App launched: source=%1").arg(plan.matchSource));
|
||||||
|
return {true, QStringLiteral("已启动:%1").arg(plan.displayName), {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredApp AppLaunchManager::registeredAppFromPlan(const AppLaunchPlan &plan, const QStringList &aliases) const
|
||||||
|
{
|
||||||
|
RegisteredApp app;
|
||||||
|
app.id = sanitizedId(plan.requestedName.isEmpty() ? plan.displayName : plan.requestedName);
|
||||||
|
app.displayName = plan.displayName;
|
||||||
|
app.aliases = aliases;
|
||||||
|
if (app.aliases.isEmpty())
|
||||||
|
{
|
||||||
|
app.aliases = {plan.requestedName.isEmpty() ? plan.displayName : plan.requestedName};
|
||||||
|
}
|
||||||
|
if (!app.aliases.contains(app.displayName, Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
app.aliases.prepend(app.displayName);
|
||||||
|
}
|
||||||
|
app.executablePath = plan.executablePath;
|
||||||
|
app.shortcutPath = plan.shortcutPath;
|
||||||
|
app.workingDirectory = plan.workingDirectory;
|
||||||
|
app.userDefined = true;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppLaunchManager::isBlockedFileOperationText(const QString &text) const
|
||||||
|
{
|
||||||
|
return containsAny(text, {
|
||||||
|
QStringLiteral("打开文件"),
|
||||||
|
QStringLiteral("打开文件夹"),
|
||||||
|
QStringLiteral("打开目录"),
|
||||||
|
QStringLiteral("读取文件"),
|
||||||
|
QStringLiteral("复制文件"),
|
||||||
|
QStringLiteral("备份文件"),
|
||||||
|
QStringLiteral("重命名文件"),
|
||||||
|
QStringLiteral("删除文件"),
|
||||||
|
QStringLiteral("移动文件"),
|
||||||
|
QStringLiteral("保存到"),
|
||||||
|
QStringLiteral("截图"),
|
||||||
|
QStringLiteral("打包"),
|
||||||
|
QStringLiteral("压缩"),
|
||||||
|
QStringLiteral("脚本"),
|
||||||
|
QStringLiteral("命令"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppLaunchManager::validateLaunchPlan(const AppLaunchPlan &plan, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QString executablePath = plan.executablePath.trimmed();
|
||||||
|
const QString shortcutPath = plan.shortcutPath.trimmed();
|
||||||
|
if (executablePath.isEmpty() && shortcutPath.isEmpty())
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("启动路径为空。");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!executablePath.isEmpty())
|
||||||
|
{
|
||||||
|
const QFileInfo info(executablePath);
|
||||||
|
const QString suffix = fileSuffix(executablePath);
|
||||||
|
if (suffix != QStringLiteral("exe"))
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("只允许启动 .exe 应用,不支持脚本、安装包或命令文件。");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!info.exists() || !info.isFile())
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("应用文件不存在。");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortcutPath.isEmpty())
|
||||||
|
{
|
||||||
|
const QString suffix = fileSuffix(shortcutPath);
|
||||||
|
if (suffix != QStringLiteral("lnk"))
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("只允许打开开始菜单快捷方式。");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!QFileInfo(shortcutPath).exists())
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("快捷方式不存在。");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsAny(executablePath + shortcutPath, {
|
||||||
|
QStringLiteral(".bat"),
|
||||||
|
QStringLiteral(".cmd"),
|
||||||
|
QStringLiteral(".ps1"),
|
||||||
|
QStringLiteral(".vbs"),
|
||||||
|
QStringLiteral(".js"),
|
||||||
|
QStringLiteral(".msi"),
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("不支持启动脚本、命令文件或安装包。");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchPlan AppLaunchManager::planFromRegisteredApp(const QString &requestedName, const RegisteredApp &app, const QString &matchSource) const
|
||||||
|
{
|
||||||
|
AppLaunchPlan plan;
|
||||||
|
plan.requestedName = requestedName;
|
||||||
|
plan.displayName = app.displayName;
|
||||||
|
plan.executablePath = app.executablePath;
|
||||||
|
plan.shortcutPath = app.shortcutPath;
|
||||||
|
plan.workingDirectory = app.workingDirectory;
|
||||||
|
plan.matchSource = matchSource;
|
||||||
|
QString errorMessage;
|
||||||
|
plan.success = validateLaunchPlan(plan, &errorMessage);
|
||||||
|
plan.errorMessage = errorMessage;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "AppDiscovery.h"
|
||||||
|
#include "AppLaunchTypes.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class AppLaunchManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool isLaunchIntentText(const QString &text) const;
|
||||||
|
QString requestedAppNameFromText(const QString &text) const;
|
||||||
|
|
||||||
|
AppLaunchPlan resolveLaunchPlan(const QString &text, const AppLaunchConfig &config) const;
|
||||||
|
AppLaunchPlan manualSelectionPlan(const QString &text, const QString &executablePath) const;
|
||||||
|
AppLaunchResult executeLaunchPlan(const AppLaunchPlan &plan) const;
|
||||||
|
RegisteredApp registeredAppFromPlan(const AppLaunchPlan &plan, const QStringList &aliases) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool isBlockedFileOperationText(const QString &text) const;
|
||||||
|
bool validateLaunchPlan(const AppLaunchPlan &plan, QString *errorMessage) const;
|
||||||
|
AppLaunchPlan planFromRegisteredApp(const QString &requestedName, const RegisteredApp &app, const QString &matchSource) const;
|
||||||
|
|
||||||
|
AppDiscovery m_discovery;
|
||||||
|
};
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
#include "AppLaunchStore.h"
|
||||||
|
|
||||||
|
#include "../util/Logger.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
const QString LauncherConfigFileName = QStringLiteral("launcher_config.json");
|
||||||
|
|
||||||
|
QString sanitizedId(const QString &value)
|
||||||
|
{
|
||||||
|
QString id = value.trimmed().toLower();
|
||||||
|
id.replace(QRegularExpression(QStringLiteral("[^a-z0-9._-]+")), QStringLiteral("_"));
|
||||||
|
id = id.trimmed();
|
||||||
|
if (id.isEmpty())
|
||||||
|
{
|
||||||
|
id = QStringLiteral("app");
|
||||||
|
}
|
||||||
|
return id.left(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList aliasesFromJson(const QJsonArray &array)
|
||||||
|
{
|
||||||
|
QStringList aliases;
|
||||||
|
for (const QJsonValue &value : array)
|
||||||
|
{
|
||||||
|
const QString alias = value.toString().trimmed();
|
||||||
|
if (!alias.isEmpty() && !aliases.contains(alias, Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
aliases.append(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray aliasesToJson(const QStringList &aliases)
|
||||||
|
{
|
||||||
|
QJsonArray array;
|
||||||
|
for (const QString &alias : aliases)
|
||||||
|
{
|
||||||
|
const QString normalized = alias.trimmed();
|
||||||
|
if (!normalized.isEmpty())
|
||||||
|
{
|
||||||
|
array.append(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredApp appFromJson(const QJsonObject &object)
|
||||||
|
{
|
||||||
|
RegisteredApp app;
|
||||||
|
app.id = sanitizedId(object.value(QStringLiteral("id")).toString());
|
||||||
|
app.displayName = object.value(QStringLiteral("displayName")).toString().trimmed();
|
||||||
|
app.aliases = aliasesFromJson(object.value(QStringLiteral("aliases")).toArray());
|
||||||
|
app.executablePath = object.value(QStringLiteral("executablePath")).toString().trimmed();
|
||||||
|
app.shortcutPath = object.value(QStringLiteral("shortcutPath")).toString().trimmed();
|
||||||
|
app.workingDirectory = object.value(QStringLiteral("workingDirectory")).toString().trimmed();
|
||||||
|
app.userDefined = object.value(QStringLiteral("userDefined")).toBool(true);
|
||||||
|
if (app.displayName.isEmpty())
|
||||||
|
{
|
||||||
|
app.displayName = QFileInfo(app.executablePath.isEmpty() ? app.shortcutPath : app.executablePath).completeBaseName();
|
||||||
|
}
|
||||||
|
if (app.aliases.isEmpty() && !app.displayName.isEmpty())
|
||||||
|
{
|
||||||
|
app.aliases.append(app.displayName);
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject jsonFromApp(const RegisteredApp &app)
|
||||||
|
{
|
||||||
|
QJsonObject object;
|
||||||
|
object.insert(QStringLiteral("id"), sanitizedId(app.id.isEmpty() ? app.displayName : app.id));
|
||||||
|
object.insert(QStringLiteral("displayName"), app.displayName.trimmed());
|
||||||
|
object.insert(QStringLiteral("aliases"), aliasesToJson(app.aliases));
|
||||||
|
object.insert(QStringLiteral("executablePath"), app.executablePath.trimmed());
|
||||||
|
object.insert(QStringLiteral("shortcutPath"), app.shortcutPath.trimmed());
|
||||||
|
object.insert(QStringLiteral("workingDirectory"), app.workingDirectory.trimmed());
|
||||||
|
object.insert(QStringLiteral("userDefined"), app.userDefined);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchConfig normalizedConfig(AppLaunchConfig config)
|
||||||
|
{
|
||||||
|
QVector<RegisteredApp> apps;
|
||||||
|
QSet<QString> ids;
|
||||||
|
for (RegisteredApp app : config.apps)
|
||||||
|
{
|
||||||
|
app.id = sanitizedId(app.id.isEmpty() ? app.displayName : app.id);
|
||||||
|
if (ids.contains(app.id))
|
||||||
|
{
|
||||||
|
int suffix = 2;
|
||||||
|
QString candidate;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
candidate = app.id + QStringLiteral("_") + QString::number(suffix++);
|
||||||
|
} while (ids.contains(candidate));
|
||||||
|
app.id = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.displayName = app.displayName.trimmed();
|
||||||
|
app.executablePath = app.executablePath.trimmed();
|
||||||
|
app.shortcutPath = app.shortcutPath.trimmed();
|
||||||
|
app.workingDirectory = app.workingDirectory.trimmed();
|
||||||
|
QStringList aliases;
|
||||||
|
for (const QString &alias : app.aliases)
|
||||||
|
{
|
||||||
|
const QString normalized = alias.trimmed();
|
||||||
|
if (!normalized.isEmpty() && !aliases.contains(normalized, Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
aliases.append(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!app.displayName.isEmpty() && !aliases.contains(app.displayName, Qt::CaseInsensitive))
|
||||||
|
{
|
||||||
|
aliases.prepend(app.displayName);
|
||||||
|
}
|
||||||
|
app.aliases = aliases;
|
||||||
|
if (app.displayName.isEmpty() || (app.executablePath.isEmpty() && app.shortcutPath.isEmpty()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.insert(app.id);
|
||||||
|
apps.append(app);
|
||||||
|
}
|
||||||
|
config.apps = apps;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchConfig AppLaunchStore::load(QString *errorMessage) const
|
||||||
|
{
|
||||||
|
AppLaunchConfig 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 launcher 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("Launcher config is broken; default config will be used."));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject root = document.object();
|
||||||
|
config.enabled = root.value(QStringLiteral("enabled")).toBool(config.enabled);
|
||||||
|
config.allowManualExeSelection = root.value(QStringLiteral("allowManualExeSelection")).toBool(config.allowManualExeSelection);
|
||||||
|
config.alwaysConfirmBeforeLaunch = root.value(QStringLiteral("alwaysConfirmBeforeLaunch")).toBool(config.alwaysConfirmBeforeLaunch);
|
||||||
|
|
||||||
|
const QJsonArray appArray = root.value(QStringLiteral("apps")).toArray();
|
||||||
|
for (const QJsonValue &value : appArray)
|
||||||
|
{
|
||||||
|
if (value.isObject())
|
||||||
|
{
|
||||||
|
config.apps.append(appFromJson(value.toObject()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizedConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppLaunchStore::save(const AppLaunchConfig &config, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
QDir directory(configDirectoryPath());
|
||||||
|
if (!directory.exists() && !directory.mkpath(QStringLiteral(".")))
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("无法创建应用启动配置目录。");
|
||||||
|
}
|
||||||
|
Logger::warning(QStringLiteral("Unable to create launcher config directory."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppLaunchConfig normalized = normalizedConfig(config);
|
||||||
|
QJsonObject root;
|
||||||
|
root.insert(QStringLiteral("enabled"), normalized.enabled);
|
||||||
|
root.insert(QStringLiteral("allowManualExeSelection"), normalized.allowManualExeSelection);
|
||||||
|
root.insert(QStringLiteral("alwaysConfirmBeforeLaunch"), normalized.alwaysConfirmBeforeLaunch);
|
||||||
|
QJsonArray appArray;
|
||||||
|
for (const RegisteredApp &app : normalized.apps)
|
||||||
|
{
|
||||||
|
appArray.append(jsonFromApp(app));
|
||||||
|
}
|
||||||
|
root.insert(QStringLiteral("apps"), appArray);
|
||||||
|
|
||||||
|
const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Indented);
|
||||||
|
QSaveFile file(storePath());
|
||||||
|
if (!file.open(QIODevice::WriteOnly))
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("无法写入应用启动配置文件。");
|
||||||
|
}
|
||||||
|
Logger::warning(QStringLiteral("Unable to open launcher 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("Launcher config write was incomplete."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.commit())
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("提交应用启动配置文件失败。");
|
||||||
|
}
|
||||||
|
Logger::warning(QStringLiteral("Unable to commit launcher config."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AppLaunchStore::storePath() const
|
||||||
|
{
|
||||||
|
return QDir(configDirectoryPath()).filePath(LauncherConfigFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AppLaunchStore::configDirectoryPath() const
|
||||||
|
{
|
||||||
|
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||||
|
if (!path.isEmpty())
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return QDir::currentPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppLaunchStore::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("launcher_config.broken.") + timestamp + QStringLiteral(".json"));
|
||||||
|
int suffix = 1;
|
||||||
|
while (QFile::exists(backupPath))
|
||||||
|
{
|
||||||
|
backupPath = fileInfo.dir().filePath(
|
||||||
|
QStringLiteral("launcher_config.broken.")
|
||||||
|
+ timestamp
|
||||||
|
+ QStringLiteral("-")
|
||||||
|
+ QString::number(suffix)
|
||||||
|
+ QStringLiteral(".json"));
|
||||||
|
++suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.rename(backupPath))
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Broken launcher config was backed up: %1").arg(backupPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::warning(QStringLiteral("Failed to back up broken launcher config: %1").arg(filePath));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "AppLaunchTypes.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class AppLaunchStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AppLaunchConfig load(QString *errorMessage = nullptr) const;
|
||||||
|
bool save(const AppLaunchConfig &config, QString *errorMessage = nullptr) const;
|
||||||
|
QString storePath() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString configDirectoryPath() const;
|
||||||
|
void backupBrokenConfig(const QString &filePath) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
struct RegisteredApp
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString displayName;
|
||||||
|
QStringList aliases;
|
||||||
|
QString executablePath;
|
||||||
|
QString shortcutPath;
|
||||||
|
QString workingDirectory;
|
||||||
|
bool userDefined = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppLaunchConfig
|
||||||
|
{
|
||||||
|
bool enabled = true;
|
||||||
|
bool allowManualExeSelection = true;
|
||||||
|
bool alwaysConfirmBeforeLaunch = true;
|
||||||
|
QVector<RegisteredApp> apps;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppLaunchPlan
|
||||||
|
{
|
||||||
|
bool success = false;
|
||||||
|
bool needsManualSelection = false;
|
||||||
|
QString requestedName;
|
||||||
|
QString displayName;
|
||||||
|
QString executablePath;
|
||||||
|
QString shortcutPath;
|
||||||
|
QString workingDirectory;
|
||||||
|
QString matchSource;
|
||||||
|
QString errorMessage;
|
||||||
|
bool canRemember = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppLaunchResult
|
||||||
|
{
|
||||||
|
bool success = false;
|
||||||
|
QString message;
|
||||||
|
QString errorMessage;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "ChatInputDialog.h"
|
#include "ChatInputDialog.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QCheckBox>
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
@@ -16,9 +17,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
|||||||
: QDialog(parent)
|
: QDialog(parent)
|
||||||
, m_textEdit(new QTextEdit(this))
|
, m_textEdit(new QTextEdit(this))
|
||||||
, m_counterLabel(new QLabel(this))
|
, m_counterLabel(new QLabel(this))
|
||||||
|
, m_webToggleCheckBox(new QCheckBox(QStringLiteral("联网"), this))
|
||||||
, m_sendButton(new QPushButton(QStringLiteral("↗"), this))
|
, m_sendButton(new QPushButton(QStringLiteral("↗"), this))
|
||||||
, m_maxLength(maxLength)
|
, m_maxLength(maxLength)
|
||||||
{
|
{
|
||||||
|
m_webToggleCheckBox->setObjectName(QStringLiteral("WebToggle"));
|
||||||
|
|
||||||
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
||||||
setAttribute(Qt::WA_TranslucentBackground);
|
setAttribute(Qt::WA_TranslucentBackground);
|
||||||
setModal(false);
|
setModal(false);
|
||||||
@@ -59,6 +63,27 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
|||||||
"QPushButton:disabled {"
|
"QPushButton:disabled {"
|
||||||
"color: rgba(32, 33, 36, 80);"
|
"color: rgba(32, 33, 36, 80);"
|
||||||
"background: rgba(255, 255, 255, 170);"
|
"background: rgba(255, 255, 255, 170);"
|
||||||
|
"}"
|
||||||
|
"QCheckBox#WebToggle {"
|
||||||
|
"color: #202124;"
|
||||||
|
"font-size: 13px;"
|
||||||
|
"font-weight: 600;"
|
||||||
|
"spacing: 5px;"
|
||||||
|
"}"
|
||||||
|
"QCheckBox#WebToggle::indicator {"
|
||||||
|
"width: 16px;"
|
||||||
|
"height: 16px;"
|
||||||
|
"border: 1px solid rgba(32, 33, 36, 70);"
|
||||||
|
"border-radius: 8px;"
|
||||||
|
"background: rgba(255, 255, 255, 235);"
|
||||||
|
"}"
|
||||||
|
"QCheckBox#WebToggle::indicator:checked {"
|
||||||
|
"background: #175cd3;"
|
||||||
|
"border: 1px solid #175cd3;"
|
||||||
|
"}"
|
||||||
|
"QCheckBox#WebToggle::indicator:disabled {"
|
||||||
|
"background: rgba(255, 255, 255, 120);"
|
||||||
|
"border: 1px solid rgba(32, 33, 36, 28);"
|
||||||
"}"));
|
"}"));
|
||||||
|
|
||||||
m_textEdit->setPlaceholderText(QStringLiteral("输入消息,Enter 发送,Shift+Enter 换行"));
|
m_textEdit->setPlaceholderText(QStringLiteral("输入消息,Enter 发送,Shift+Enter 换行"));
|
||||||
@@ -73,6 +98,8 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
|||||||
m_counterLabel->setStyleSheet(QStringLiteral("color: #b3261e; font-size: 12px;"));
|
m_counterLabel->setStyleSheet(QStringLiteral("color: #b3261e; font-size: 12px;"));
|
||||||
m_counterLabel->setVisible(false);
|
m_counterLabel->setVisible(false);
|
||||||
|
|
||||||
|
m_webToggleCheckBox->setToolTip(QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。"));
|
||||||
|
|
||||||
connect(m_sendButton, &QPushButton::clicked, this, [this]() {
|
connect(m_sendButton, &QPushButton::clicked, this, [this]() {
|
||||||
submitIfValid();
|
submitIfValid();
|
||||||
});
|
});
|
||||||
@@ -89,6 +116,7 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
|||||||
panelLayout->setSpacing(8);
|
panelLayout->setSpacing(8);
|
||||||
panelLayout->addWidget(m_textEdit, 1);
|
panelLayout->addWidget(m_textEdit, 1);
|
||||||
panelLayout->addWidget(m_counterLabel);
|
panelLayout->addWidget(m_counterLabel);
|
||||||
|
panelLayout->addWidget(m_webToggleCheckBox);
|
||||||
panelLayout->addWidget(m_sendButton);
|
panelLayout->addWidget(m_sendButton);
|
||||||
|
|
||||||
auto *layout = new QHBoxLayout(this);
|
auto *layout = new QHBoxLayout(this);
|
||||||
@@ -104,6 +132,38 @@ QString ChatInputDialog::message() const
|
|||||||
return m_textEdit->toPlainText().trimmed();
|
return m_textEdit->toPlainText().trimmed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ChatInputDialog::webEnabled() const
|
||||||
|
{
|
||||||
|
return m_webToggleCheckBox != nullptr
|
||||||
|
&& m_webToggleCheckBox->isEnabled()
|
||||||
|
&& m_webToggleCheckBox->isChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatInputDialog::setWebEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_webToggleCheckBox)
|
||||||
|
{
|
||||||
|
m_webToggleCheckBox->setChecked(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatInputDialog::setWebToggleAvailable(bool available, const QString &toolTip)
|
||||||
|
{
|
||||||
|
if (!m_webToggleCheckBox)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_webToggleCheckBox->setEnabled(available);
|
||||||
|
if (!available)
|
||||||
|
{
|
||||||
|
m_webToggleCheckBox->setChecked(false);
|
||||||
|
}
|
||||||
|
m_webToggleCheckBox->setToolTip(toolTip.trimmed().isEmpty()
|
||||||
|
? QStringLiteral("开启后使用当前 AI Provider 的原生联网能力。")
|
||||||
|
: toolTip);
|
||||||
|
}
|
||||||
|
|
||||||
void ChatInputDialog::setSubmitCallback(SubmitCallback callback)
|
void ChatInputDialog::setSubmitCallback(SubmitCallback callback)
|
||||||
{
|
{
|
||||||
m_submitCallback = std::move(callback);
|
m_submitCallback = std::move(callback);
|
||||||
@@ -217,7 +277,7 @@ bool ChatInputDialog::submitIfValid()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString submittedMessage = message();
|
const QString submittedMessage = message();
|
||||||
const bool accepted = m_submitCallback ? m_submitCallback(submittedMessage) : true;
|
const bool accepted = m_submitCallback ? m_submitCallback(submittedMessage, webEnabled()) : true;
|
||||||
if (accepted)
|
if (accepted)
|
||||||
{
|
{
|
||||||
clearMessage();
|
clearMessage();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
||||||
class QEvent;
|
class QEvent;
|
||||||
|
class QCheckBox;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QMouseEvent;
|
class QMouseEvent;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
@@ -15,11 +16,14 @@ class QTextEdit;
|
|||||||
class ChatInputDialog : public QDialog
|
class ChatInputDialog : public QDialog
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
using SubmitCallback = std::function<bool(const QString &)>;
|
using SubmitCallback = std::function<bool(const QString &, bool)>;
|
||||||
|
|
||||||
explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr);
|
explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr);
|
||||||
|
|
||||||
QString message() const;
|
QString message() const;
|
||||||
|
bool webEnabled() const;
|
||||||
|
void setWebEnabled(bool enabled);
|
||||||
|
void setWebToggleAvailable(bool available, const QString &toolTip = QString());
|
||||||
void setSubmitCallback(SubmitCallback callback);
|
void setSubmitCallback(SubmitCallback callback);
|
||||||
void showAt(const QPoint &anchorPosition);
|
void showAt(const QPoint &anchorPosition);
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ private:
|
|||||||
|
|
||||||
QTextEdit *m_textEdit = nullptr;
|
QTextEdit *m_textEdit = nullptr;
|
||||||
QLabel *m_counterLabel = nullptr;
|
QLabel *m_counterLabel = nullptr;
|
||||||
|
QCheckBox *m_webToggleCheckBox = nullptr;
|
||||||
QPushButton *m_sendButton = nullptr;
|
QPushButton *m_sendButton = nullptr;
|
||||||
SubmitCallback m_submitCallback;
|
SubmitCallback m_submitCallback;
|
||||||
QPoint m_dragStartPosition;
|
QPoint m_dragStartPosition;
|
||||||
|
|||||||
+699
-9
@@ -7,12 +7,21 @@
|
|||||||
#include "../character/CharacterPackageLoader.h"
|
#include "../character/CharacterPackageLoader.h"
|
||||||
#include "../character/CharacterPackageRepository.h"
|
#include "../character/CharacterPackageRepository.h"
|
||||||
#include "../config/ConfigManager.h"
|
#include "../config/ConfigManager.h"
|
||||||
|
#include "../fileops/FileOperationManager.h"
|
||||||
|
#include "../launcher/AppLaunchManager.h"
|
||||||
|
#include "../launcher/AppLaunchStore.h"
|
||||||
#include "../notification/NotificationDispatcher.h"
|
#include "../notification/NotificationDispatcher.h"
|
||||||
#include "../reminder/ReminderCommandHandler.h"
|
#include "../reminder/ReminderCommandHandler.h"
|
||||||
#include "../reminder/ReminderManager.h"
|
#include "../reminder/ReminderManager.h"
|
||||||
#include "../reminder/ReminderSoundPlayer.h"
|
#include "../reminder/ReminderSoundPlayer.h"
|
||||||
#include "../reminder/ReminderSoundRepository.h"
|
#include "../reminder/ReminderSoundRepository.h"
|
||||||
|
#include "../system/StartupManager.h"
|
||||||
#include "../util/Logger.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 "ChatBubble.h"
|
||||||
#include "ChatHistoryPanel.h"
|
#include "ChatHistoryPanel.h"
|
||||||
#include "ChatInputDialog.h"
|
#include "ChatInputDialog.h"
|
||||||
@@ -24,16 +33,25 @@
|
|||||||
#include <QContextMenuEvent>
|
#include <QContextMenuEvent>
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QFont>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QHideEvent>
|
#include <QHideEvent>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QLabel>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
#include <QLineEdit>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QPointF>
|
#include <QPointF>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QCheckBox>
|
||||||
#include <QRandomGenerator>
|
#include <QRandomGenerator>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
@@ -81,6 +99,7 @@ QString megabytesText(qint64 bytes)
|
|||||||
|
|
||||||
AppConfig normalizedAppConfig(AppConfig config)
|
AppConfig normalizedAppConfig(AppConfig config)
|
||||||
{
|
{
|
||||||
|
config.launchAtStartup = StartupManager::isLaunchAtStartupEnabled();
|
||||||
config.scale = qBound(0.5, config.scale, 2.0);
|
config.scale = qBound(0.5, config.scale, 2.0);
|
||||||
if (config.performanceMode != QStringLiteral("standard")
|
if (config.performanceMode != QStringLiteral("standard")
|
||||||
&& config.performanceMode != QStringLiteral("low-power"))
|
&& config.performanceMode != QStringLiteral("low-power"))
|
||||||
@@ -157,6 +176,22 @@ QString userVisibleErrorMessage(const ChatResponse &response)
|
|||||||
return message;
|
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)
|
PetWindow::PetWindow(QWidget *parent)
|
||||||
@@ -166,9 +201,13 @@ PetWindow::PetWindow(QWidget *parent)
|
|||||||
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
|
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
|
||||||
, m_conversationManager(std::make_unique<ConversationManager>())
|
, m_conversationManager(std::make_unique<ConversationManager>())
|
||||||
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
|
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
|
||||||
|
, m_fileOperationManager(std::make_unique<FileOperationManager>())
|
||||||
|
, m_appLaunchManager(std::make_unique<AppLaunchManager>())
|
||||||
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
|
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
|
||||||
, m_reminderManager(std::make_unique<ReminderManager>())
|
, m_reminderManager(std::make_unique<ReminderManager>())
|
||||||
, m_reminderSoundPlayer(std::make_unique<ReminderSoundPlayer>())
|
, m_reminderSoundPlayer(std::make_unique<ReminderSoundPlayer>())
|
||||||
|
, m_webChatManager(std::make_unique<WebChatManager>())
|
||||||
|
, m_weatherManager(std::make_unique<WeatherManager>())
|
||||||
, m_petView(new PetView(this))
|
, m_petView(new PetView(this))
|
||||||
, m_dragging(false)
|
, m_dragging(false)
|
||||||
, m_alwaysOnTop(true)
|
, m_alwaysOnTop(true)
|
||||||
@@ -212,8 +251,9 @@ PetWindow::PetWindow(QWidget *parent)
|
|||||||
});
|
});
|
||||||
|
|
||||||
QPointer<PetWindow> window(this);
|
QPointer<PetWindow> window(this);
|
||||||
m_chatInputDialog->setSubmitCallback([window](const QString &message) {
|
refreshChatInputWebToggle();
|
||||||
return !window.isNull() && window->submitChatMessage(message);
|
m_chatInputDialog->setSubmitCallback([window](const QString &message, bool webEnabled) {
|
||||||
|
return !window.isNull() && window->submitChatMessage(message, webEnabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) {
|
m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) {
|
||||||
@@ -299,6 +339,7 @@ AppConfig PetWindow::currentAppConfig() const
|
|||||||
config.windowPosition = pos();
|
config.windowPosition = pos();
|
||||||
config.hasWindowPosition = true;
|
config.hasWindowPosition = true;
|
||||||
config.alwaysOnTop = m_alwaysOnTop;
|
config.alwaysOnTop = m_alwaysOnTop;
|
||||||
|
config.launchAtStartup = StartupManager::isLaunchAtStartupEnabled();
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,9 +383,35 @@ void PetWindow::showBubbleMessage(const QString &message)
|
|||||||
void PetWindow::openSettingsDialog()
|
void PetWindow::openSettingsDialog()
|
||||||
{
|
{
|
||||||
ConfigManager configManager;
|
ConfigManager configManager;
|
||||||
|
WeatherStore weatherStore;
|
||||||
|
WebStore webStore;
|
||||||
|
AppLaunchStore appLaunchStore;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
QString appLaunchConfigError;
|
||||||
|
const AppLaunchConfig appLaunchConfig = appLaunchStore.load(&appLaunchConfigError);
|
||||||
|
if (!appLaunchConfigError.isEmpty())
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Launcher config load warning: ") + appLaunchConfigError);
|
||||||
|
}
|
||||||
|
|
||||||
SettingsDialog dialog(
|
SettingsDialog dialog(
|
||||||
configManager.loadAIConfigStore(),
|
configManager.loadAIConfigStore(),
|
||||||
currentAppConfig(),
|
currentAppConfig(),
|
||||||
|
weatherConfig,
|
||||||
|
webConfig,
|
||||||
|
appLaunchConfig,
|
||||||
|
m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>(),
|
||||||
m_reminderManager ? m_reminderManager->allReminders() : QVector<ReminderItem>(),
|
m_reminderManager ? m_reminderManager->allReminders() : QVector<ReminderItem>(),
|
||||||
[this]() {
|
[this]() {
|
||||||
return isManualStateSwitchLocked();
|
return isManualStateSwitchLocked();
|
||||||
@@ -415,11 +482,42 @@ void PetWindow::openSettingsDialog()
|
|||||||
Logger::warning(QStringLiteral("Failed to save AI config from settings dialog."));
|
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()))
|
if (!configManager.saveAppConfig(currentAppConfig()))
|
||||||
{
|
{
|
||||||
Logger::warning(QStringLiteral("Failed to save app config from settings dialog."));
|
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();
|
||||||
|
|
||||||
|
QString saveLauncherConfigError;
|
||||||
|
if (!appLaunchStore.save(dialog.appLaunchConfig(), &saveLauncherConfigError))
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Failed to save launcher config from settings dialog: ") + saveLauncherConfigError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PetWindow::activateFromExternalInstance()
|
void PetWindow::activateFromExternalInstance()
|
||||||
@@ -478,7 +576,7 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event)
|
|||||||
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
|
QAction *chatAction = menu.addAction(QStringLiteral("聊天"));
|
||||||
chatAction->setEnabled(!aiRequestRunning);
|
chatAction->setEnabled(!aiRequestRunning);
|
||||||
QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话"));
|
QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话"));
|
||||||
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消 AI 请求"));
|
QAction *cancelAIAction = menu.addAction(QStringLiteral("取消当前请求"));
|
||||||
cancelAIAction->setEnabled(aiRequestRunning);
|
cancelAIAction->setEnabled(aiRequestRunning);
|
||||||
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
|
QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话"));
|
||||||
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
|
clearConversationAction->setEnabled(m_conversationManager && m_conversationManager->hasHistory());
|
||||||
@@ -540,10 +638,11 @@ void PetWindow::startChat()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshChatInputWebToggle();
|
||||||
m_chatInputDialog->showAt(chatInputAnchorPosition());
|
m_chatInputDialog->showAt(chatInputAnchorPosition());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PetWindow::submitChatMessage(const QString &message)
|
bool PetWindow::submitChatMessage(const QString &message, bool webEnabled)
|
||||||
{
|
{
|
||||||
const QString normalizedMessage = message.trimmed();
|
const QString normalizedMessage = message.trimmed();
|
||||||
if (normalizedMessage.isEmpty())
|
if (normalizedMessage.isEmpty())
|
||||||
@@ -565,6 +664,21 @@ bool PetWindow::submitChatMessage(const QString &message)
|
|||||||
return handleReminderChatMessage(result.message);
|
return handleReminderChatMessage(result.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.action == CommandDispatchAction::Weather)
|
||||||
|
{
|
||||||
|
return handleWeatherChatMessage(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.action == CommandDispatchAction::FileOperation)
|
||||||
|
{
|
||||||
|
return handleFileOperationChatMessage(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.action == CommandDispatchAction::LaunchApp)
|
||||||
|
{
|
||||||
|
return handleLaunchAppChatMessage(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.action == CommandDispatchAction::UnsupportedTool)
|
if (result.action == CommandDispatchAction::UnsupportedTool)
|
||||||
{
|
{
|
||||||
playState(QStringLiteral("talk"), false);
|
playState(QStringLiteral("talk"), false);
|
||||||
@@ -572,7 +686,8 @@ bool PetWindow::submitChatMessage(const QString &message)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return submitAiChatMessage(result.message);
|
saveWebTogglePreference(webEnabled);
|
||||||
|
return webEnabled ? submitWebChatMessage(result.message) : submitAiChatMessage(result.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PetWindow::handleReminderChatMessage(const QString &message)
|
bool PetWindow::handleReminderChatMessage(const QString &message)
|
||||||
@@ -592,6 +707,562 @@ bool PetWindow::handleReminderChatMessage(const QString &message)
|
|||||||
return result.success;
|
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::handleFileOperationChatMessage(const QString &message)
|
||||||
|
{
|
||||||
|
if (!m_fileOperationManager)
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("error"), false);
|
||||||
|
showBubbleMessage(QStringLiteral("文件操作功能初始化失败。"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString text = message.trimmed();
|
||||||
|
const auto contains = [&text](const QString &keyword) {
|
||||||
|
return text.contains(keyword, Qt::CaseInsensitive);
|
||||||
|
};
|
||||||
|
if (contains(QStringLiteral("删除")) || contains(QStringLiteral("移动")) || contains(QStringLiteral("覆盖"))
|
||||||
|
|| contains(QStringLiteral("执行")) || contains(QStringLiteral("运行")) || contains(QStringLiteral("脚本")) || contains(QStringLiteral("命令")))
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("error"), false);
|
||||||
|
showBubbleMessage(QStringLiteral("文件操作 v1 不支持删除、覆盖、移动、执行脚本或运行命令。"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contains(QStringLiteral("截图")) || contains(QStringLiteral("保存到")))
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("talk"), false);
|
||||||
|
showBubbleMessage(QStringLiteral("文件操作 v1 暂不支持截图或把当前内容保存到指定位置。"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contains(QStringLiteral("打包")) || contains(QStringLiteral("压缩")) || contains(QStringLiteral("zip")))
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("talk"), false);
|
||||||
|
showBubbleMessage(QStringLiteral("zip 打包需要额外稳定的压缩实现,本版暂不启用;可以先使用复制或备份。"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto confirmPlan = [this](const FileOperationPlan &plan) {
|
||||||
|
QString messageText = plan.description;
|
||||||
|
if (!plan.warnings.isEmpty())
|
||||||
|
{
|
||||||
|
messageText += QStringLiteral("\n\n注意:\n") + plan.warnings.join(QLatin1Char('\n'));
|
||||||
|
}
|
||||||
|
messageText += QStringLiteral("\n\n请确认是否执行。");
|
||||||
|
return QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
plan.title,
|
||||||
|
messageText,
|
||||||
|
QMessageBox::Yes | QMessageBox::Cancel,
|
||||||
|
QMessageBox::Cancel) == QMessageBox::Yes;
|
||||||
|
};
|
||||||
|
|
||||||
|
FileOperationResult operationResult;
|
||||||
|
if (contains(QStringLiteral("列出")) || contains(QStringLiteral("目录")) || contains(QStringLiteral("文件夹")))
|
||||||
|
{
|
||||||
|
const QString directoryPath = QFileDialog::getExistingDirectory(
|
||||||
|
this,
|
||||||
|
QStringLiteral("选择要列出的文件夹"),
|
||||||
|
QString(),
|
||||||
|
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||||
|
if (directoryPath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
operationResult = m_fileOperationManager->executeListDirectory(m_fileOperationManager->listDirectoryPlan(directoryPath));
|
||||||
|
}
|
||||||
|
else if (contains(QStringLiteral("复制")))
|
||||||
|
{
|
||||||
|
const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要复制的文件"));
|
||||||
|
if (sourceFilePath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QString targetDirectoryPath = QFileDialog::getExistingDirectory(
|
||||||
|
this,
|
||||||
|
QStringLiteral("选择复制到的文件夹"),
|
||||||
|
QString(),
|
||||||
|
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||||
|
if (targetDirectoryPath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileOperationPlan plan = m_fileOperationManager->copyFilePlan(sourceFilePath, targetDirectoryPath);
|
||||||
|
if (!confirmPlan(plan))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
operationResult = m_fileOperationManager->executeCopyFile(plan);
|
||||||
|
}
|
||||||
|
else if (contains(QStringLiteral("备份")))
|
||||||
|
{
|
||||||
|
const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要备份的文件"));
|
||||||
|
if (sourceFilePath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileOperationPlan plan = m_fileOperationManager->backupFilePlan(sourceFilePath);
|
||||||
|
if (!confirmPlan(plan))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
operationResult = m_fileOperationManager->executeBackupFile(plan);
|
||||||
|
}
|
||||||
|
else if (contains(QStringLiteral("重命名")))
|
||||||
|
{
|
||||||
|
const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要重命名的文件"));
|
||||||
|
if (sourceFilePath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
const QString newFileName = QInputDialog::getText(
|
||||||
|
this,
|
||||||
|
QStringLiteral("重命名文件"),
|
||||||
|
QStringLiteral("新文件名"),
|
||||||
|
QLineEdit::Normal,
|
||||||
|
QFileInfo(sourceFilePath).fileName(),
|
||||||
|
&ok).trimmed();
|
||||||
|
if (!ok || newFileName.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileOperationPlan plan = m_fileOperationManager->renameFilePlan(sourceFilePath, newFileName);
|
||||||
|
if (!confirmPlan(plan))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
operationResult = m_fileOperationManager->executeRenameFile(plan);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const QString sourceFilePath = QFileDialog::getOpenFileName(
|
||||||
|
this,
|
||||||
|
QStringLiteral("选择要读取的文本文件"),
|
||||||
|
QString(),
|
||||||
|
QStringLiteral("Text Files (*.txt *.md *.markdown *.log *.json *.csv *.ini *.xml *.yaml *.yml);;All Files (*)"));
|
||||||
|
if (sourceFilePath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
operationResult = m_fileOperationManager->executeReadTextFile(m_fileOperationManager->readTextFilePlan(sourceFilePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!operationResult.success)
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("error"), false);
|
||||||
|
showBubbleMessage(operationResult.errorMessage.isEmpty() ? QStringLiteral("文件操作失败。") : operationResult.errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
playState(QStringLiteral("talk"), false);
|
||||||
|
const QString output = operationResult.outputText.trimmed();
|
||||||
|
showBubbleMessage(output.isEmpty() ? operationResult.message : output);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PetWindow::handleLaunchAppChatMessage(const QString &message)
|
||||||
|
{
|
||||||
|
if (!m_appLaunchManager)
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("error"), false);
|
||||||
|
showBubbleMessage(QStringLiteral("应用启动功能初始化失败。"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchStore store;
|
||||||
|
QString loadError;
|
||||||
|
AppLaunchConfig config = store.load(&loadError);
|
||||||
|
if (!loadError.isEmpty())
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Launcher config load warning: ") + loadError);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLaunchPlan plan = m_appLaunchManager->resolveLaunchPlan(message, config);
|
||||||
|
if (plan.needsManualSelection)
|
||||||
|
{
|
||||||
|
const QString executablePath = QFileDialog::getOpenFileName(
|
||||||
|
this,
|
||||||
|
QStringLiteral("选择要启动的应用"),
|
||||||
|
QString(),
|
||||||
|
QStringLiteral("Applications (*.exe)"));
|
||||||
|
if (executablePath.isEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = m_appLaunchManager->manualSelectionPlan(message, executablePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plan.success)
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("error"), false);
|
||||||
|
showBubbleMessage(plan.errorMessage.isEmpty() ? QStringLiteral("没有找到可启动的应用。") : plan.errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString confirmText = QStringLiteral("应用:%1\n来源:%2")
|
||||||
|
.arg(plan.displayName.isEmpty() ? plan.requestedName : plan.displayName,
|
||||||
|
plan.matchSource.isEmpty() ? QStringLiteral("未知") : plan.matchSource);
|
||||||
|
if (!plan.executablePath.trimmed().isEmpty())
|
||||||
|
{
|
||||||
|
confirmText += QStringLiteral("\n路径:") + plan.executablePath;
|
||||||
|
}
|
||||||
|
if (!plan.shortcutPath.trimmed().isEmpty())
|
||||||
|
{
|
||||||
|
confirmText += QStringLiteral("\n快捷方式:") + plan.shortcutPath;
|
||||||
|
}
|
||||||
|
if (!plan.workingDirectory.trimmed().isEmpty())
|
||||||
|
{
|
||||||
|
confirmText += QStringLiteral("\n工作目录:") + plan.workingDirectory;
|
||||||
|
}
|
||||||
|
QDialog confirmDialog(this);
|
||||||
|
confirmDialog.setWindowTitle(QStringLiteral("启动应用"));
|
||||||
|
confirmDialog.setModal(true);
|
||||||
|
|
||||||
|
auto *confirmLayout = new QVBoxLayout(&confirmDialog);
|
||||||
|
confirmLayout->setContentsMargins(18, 18, 18, 14);
|
||||||
|
confirmLayout->setSpacing(12);
|
||||||
|
|
||||||
|
auto *questionLabel = new QLabel(QStringLiteral("确认启动该应用?"), &confirmDialog);
|
||||||
|
QFont questionFont = questionLabel->font();
|
||||||
|
questionFont.setBold(true);
|
||||||
|
questionLabel->setFont(questionFont);
|
||||||
|
confirmLayout->addWidget(questionLabel);
|
||||||
|
|
||||||
|
auto *detailLabel = new QLabel(confirmText, &confirmDialog);
|
||||||
|
detailLabel->setWordWrap(true);
|
||||||
|
detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
detailLabel->setMinimumWidth(520);
|
||||||
|
confirmLayout->addWidget(detailLabel);
|
||||||
|
|
||||||
|
auto *rememberCheckBox = new QCheckBox(QStringLiteral("记住为此名称,下次直接匹配"), &confirmDialog);
|
||||||
|
rememberCheckBox->setVisible(plan.canRemember);
|
||||||
|
confirmLayout->addWidget(rememberCheckBox);
|
||||||
|
|
||||||
|
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &confirmDialog);
|
||||||
|
if (QPushButton *okButton = buttonBox->button(QDialogButtonBox::Ok))
|
||||||
|
{
|
||||||
|
okButton->setText(QStringLiteral("启动"));
|
||||||
|
}
|
||||||
|
if (QPushButton *cancelButton = buttonBox->button(QDialogButtonBox::Cancel))
|
||||||
|
{
|
||||||
|
cancelButton->setText(QStringLiteral("取消"));
|
||||||
|
}
|
||||||
|
QObject::connect(buttonBox, &QDialogButtonBox::accepted, &confirmDialog, &QDialog::accept);
|
||||||
|
QObject::connect(buttonBox, &QDialogButtonBox::rejected, &confirmDialog, &QDialog::reject);
|
||||||
|
confirmLayout->addWidget(buttonBox);
|
||||||
|
|
||||||
|
if (confirmDialog.exec() != QDialog::Accepted)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppLaunchResult result = m_appLaunchManager->executeLaunchPlan(plan);
|
||||||
|
if (!result.success)
|
||||||
|
{
|
||||||
|
playState(QStringLiteral("error"), false);
|
||||||
|
showBubbleMessage(result.errorMessage.isEmpty() ? QStringLiteral("启动应用失败。") : result.errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString bubbleText = result.message;
|
||||||
|
if (plan.canRemember && rememberCheckBox->isChecked())
|
||||||
|
{
|
||||||
|
RegisteredApp app = m_appLaunchManager->registeredAppFromPlan(
|
||||||
|
plan,
|
||||||
|
{plan.requestedName, plan.displayName});
|
||||||
|
|
||||||
|
bool replaced = false;
|
||||||
|
for (RegisteredApp &existingApp : config.apps)
|
||||||
|
{
|
||||||
|
if (existingApp.id == app.id)
|
||||||
|
{
|
||||||
|
existingApp = app;
|
||||||
|
replaced = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!replaced)
|
||||||
|
{
|
||||||
|
config.apps.append(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString saveError;
|
||||||
|
if (!store.save(config, &saveError))
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Failed to save launcher config after manual app selection: ") + saveError);
|
||||||
|
bubbleText += QStringLiteral("\n但保存应用别名失败。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playState(QStringLiteral("talk"), false);
|
||||||
|
showBubbleMessage(bubbleText.isEmpty() ? QStringLiteral("应用已启动。") : bubbleText);
|
||||||
|
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)
|
void PetWindow::handleTriggeredReminder(const ReminderItem &item)
|
||||||
{
|
{
|
||||||
playReminderSound();
|
playReminderSound();
|
||||||
@@ -831,6 +1502,12 @@ void PetWindow::snoozeTriggeredReminder(const ReminderItem &item)
|
|||||||
|
|
||||||
bool PetWindow::submitAiChatMessage(const QString &message)
|
bool PetWindow::submitAiChatMessage(const QString &message)
|
||||||
{
|
{
|
||||||
|
if (hasActiveWebRequest())
|
||||||
|
{
|
||||||
|
showBubbleMessage(QStringLiteral("当前联网请求正在进行,请稍后。"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!m_conversationManager || m_conversationManager->isBusy())
|
if (!m_conversationManager || m_conversationManager->isBusy())
|
||||||
{
|
{
|
||||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||||
@@ -861,6 +1538,7 @@ bool PetWindow::submitAiChatMessage(const QString &message)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_conversationManager->setConversationMetadata(config.provider, config.model);
|
||||||
if (!m_conversationManager->setProvider(std::move(provider)))
|
if (!m_conversationManager->setProvider(std::move(provider)))
|
||||||
{
|
{
|
||||||
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
showBubbleMessage(QStringLiteral("AI 回复正在进行。"));
|
||||||
@@ -935,9 +1613,13 @@ void PetWindow::clearConversation()
|
|||||||
Logger::warning(QStringLiteral("Failed to clear persisted conversation history."));
|
Logger::warning(QStringLiteral("Failed to clear persisted conversation history."));
|
||||||
}
|
}
|
||||||
cancelStreamingChat();
|
cancelStreamingChat();
|
||||||
|
if (m_webChatManager && m_webChatManager->isBusy())
|
||||||
|
{
|
||||||
|
m_webChatManager->cancel();
|
||||||
|
}
|
||||||
refreshChatHistoryPanel();
|
refreshChatHistoryPanel();
|
||||||
showBubbleMessage(hadActiveRequest
|
showBubbleMessage(hadActiveRequest
|
||||||
? QStringLiteral("已取消 AI 请求,并清空对话。")
|
? QStringLiteral("已取消当前请求,并清空对话。")
|
||||||
: QStringLiteral("对话已清空。"));
|
: QStringLiteral("对话已清空。"));
|
||||||
playState(QStringLiteral("idle"), false);
|
playState(QStringLiteral("idle"), false);
|
||||||
}
|
}
|
||||||
@@ -953,12 +1635,20 @@ void PetWindow::cancelActiveAIRequest()
|
|||||||
return;
|
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
|
bool PetWindow::hasActiveAIRequest() const
|
||||||
{
|
{
|
||||||
return m_conversationManager && m_conversationManager->isBusy();
|
return (m_conversationManager && m_conversationManager->isBusy()) || hasActiveWebRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PetWindow::isManualStateSwitchLocked() const
|
bool PetWindow::isManualStateSwitchLocked() const
|
||||||
|
|||||||
+18
-1
@@ -5,6 +5,7 @@
|
|||||||
#include "../character/FrameAnimator.h"
|
#include "../character/FrameAnimator.h"
|
||||||
#include "../config/AppConfig.h"
|
#include "../config/AppConfig.h"
|
||||||
#include "../reminder/ReminderTypes.h"
|
#include "../reminder/ReminderTypes.h"
|
||||||
|
#include "../web/WebChatTypes.h"
|
||||||
#include "../state/PetStateMachine.h"
|
#include "../state/PetStateMachine.h"
|
||||||
|
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
@@ -29,10 +30,14 @@ class ChatHistoryPanel;
|
|||||||
class ChatInputDialog;
|
class ChatInputDialog;
|
||||||
class ConversationManager;
|
class ConversationManager;
|
||||||
class ConversationStore;
|
class ConversationStore;
|
||||||
|
class FileOperationManager;
|
||||||
|
class AppLaunchManager;
|
||||||
class NotificationDispatcher;
|
class NotificationDispatcher;
|
||||||
class PetView;
|
class PetView;
|
||||||
class ReminderManager;
|
class ReminderManager;
|
||||||
class ReminderSoundPlayer;
|
class ReminderSoundPlayer;
|
||||||
|
class WebChatManager;
|
||||||
|
class WeatherManager;
|
||||||
|
|
||||||
class PetWindow : public QWidget
|
class PetWindow : public QWidget
|
||||||
{
|
{
|
||||||
@@ -66,9 +71,17 @@ private:
|
|||||||
void buildAnimationClips();
|
void buildAnimationClips();
|
||||||
void addStateTestActions(QMenu *menu);
|
void addStateTestActions(QMenu *menu);
|
||||||
void startChat();
|
void startChat();
|
||||||
bool submitChatMessage(const QString &message);
|
bool submitChatMessage(const QString &message, bool webEnabled);
|
||||||
bool submitAiChatMessage(const QString &message);
|
bool submitAiChatMessage(const QString &message);
|
||||||
|
bool submitWebChatMessage(const QString &message);
|
||||||
bool handleReminderChatMessage(const QString &message);
|
bool handleReminderChatMessage(const QString &message);
|
||||||
|
bool handleWeatherChatMessage(const QString &message);
|
||||||
|
bool handleFileOperationChatMessage(const QString &message);
|
||||||
|
bool handleLaunchAppChatMessage(const QString &message);
|
||||||
|
bool hasActiveWebRequest() const;
|
||||||
|
QString formatWebChatResponseForDisplay(const WebChatResponse &response) const;
|
||||||
|
void refreshChatInputWebToggle();
|
||||||
|
void saveWebTogglePreference(bool webEnabled);
|
||||||
void handleTriggeredReminder(const ReminderItem &item);
|
void handleTriggeredReminder(const ReminderItem &item);
|
||||||
void playReminderSound();
|
void playReminderSound();
|
||||||
void showReminderNotification(const ReminderItem &item);
|
void showReminderNotification(const ReminderItem &item);
|
||||||
@@ -129,9 +142,13 @@ private:
|
|||||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||||
std::unique_ptr<ConversationStore> m_conversationStore;
|
std::unique_ptr<ConversationStore> m_conversationStore;
|
||||||
|
std::unique_ptr<FileOperationManager> m_fileOperationManager;
|
||||||
|
std::unique_ptr<AppLaunchManager> m_appLaunchManager;
|
||||||
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
|
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
|
||||||
std::unique_ptr<ReminderManager> m_reminderManager;
|
std::unique_ptr<ReminderManager> m_reminderManager;
|
||||||
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
|
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
|
||||||
|
std::unique_ptr<WebChatManager> m_webChatManager;
|
||||||
|
std::unique_ptr<WeatherManager> m_weatherManager;
|
||||||
std::unique_ptr<QWidget> m_reminderActionPanel;
|
std::unique_ptr<QWidget> m_reminderActionPanel;
|
||||||
PetView *m_petView;
|
PetView *m_petView;
|
||||||
QTimer m_idleBehaviorTimer;
|
QTimer m_idleBehaviorTimer;
|
||||||
|
|||||||
+1129
-1
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
#include "../config/AIConfig.h"
|
#include "../config/AIConfig.h"
|
||||||
#include "../config/AppConfig.h"
|
#include "../config/AppConfig.h"
|
||||||
|
#include "../ai/LLMTypes.h"
|
||||||
|
#include "../launcher/AppLaunchTypes.h"
|
||||||
#include "../reminder/ReminderTypes.h"
|
#include "../reminder/ReminderTypes.h"
|
||||||
|
#include "../web/WebConfig.h"
|
||||||
|
#include "../weather/WeatherConfig.h"
|
||||||
|
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
@@ -19,6 +23,9 @@ class QListWidget;
|
|||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QSpinBox;
|
class QSpinBox;
|
||||||
class LLMProvider;
|
class LLMProvider;
|
||||||
|
class AppLaunchManager;
|
||||||
|
class WebChatManager;
|
||||||
|
class WeatherManager;
|
||||||
|
|
||||||
class SettingsDialog : public QDialog
|
class SettingsDialog : public QDialog
|
||||||
{
|
{
|
||||||
@@ -26,6 +33,10 @@ public:
|
|||||||
explicit SettingsDialog(
|
explicit SettingsDialog(
|
||||||
const AIConfigStore &configStore,
|
const AIConfigStore &configStore,
|
||||||
const AppConfig &appConfig,
|
const AppConfig &appConfig,
|
||||||
|
const WeatherConfig &weatherConfig,
|
||||||
|
const WebConfig &webConfig,
|
||||||
|
const AppLaunchConfig &appLaunchConfig,
|
||||||
|
const QVector<ChatMessage> &conversationHistory,
|
||||||
const QVector<ReminderItem> &reminders,
|
const QVector<ReminderItem> &reminders,
|
||||||
std::function<bool()> aiTestBlocked,
|
std::function<bool()> aiTestBlocked,
|
||||||
std::function<void()> clearConversationHistoryCallback,
|
std::function<void()> clearConversationHistoryCallback,
|
||||||
@@ -38,6 +49,9 @@ public:
|
|||||||
|
|
||||||
AIConfigStore aiConfigStore() const;
|
AIConfigStore aiConfigStore() const;
|
||||||
AppConfig appConfig() const;
|
AppConfig appConfig() const;
|
||||||
|
WeatherConfig weatherConfig() const;
|
||||||
|
WebConfig webConfig() const;
|
||||||
|
AppLaunchConfig appLaunchConfig() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void accept() override;
|
void accept() override;
|
||||||
@@ -53,9 +67,15 @@ private:
|
|||||||
void testConnection();
|
void testConnection();
|
||||||
void setTestStatus(const QString &message, const QString &state);
|
void setTestStatus(const QString &message, const QString &state);
|
||||||
void clearConversationHistory();
|
void clearConversationHistory();
|
||||||
|
void reloadConversationHistoryList();
|
||||||
|
QVector<ChatMessage> filteredConversationHistory() const;
|
||||||
|
void exportConversationHistoryMarkdown();
|
||||||
|
void exportConversationHistoryJson();
|
||||||
void reloadCharacterList(const QString &selectedCharacterId = {});
|
void reloadCharacterList(const QString &selectedCharacterId = {});
|
||||||
void importCharacterFolder();
|
void importCharacterFolder();
|
||||||
void deleteSelectedCharacter();
|
void deleteSelectedCharacter();
|
||||||
|
void exportSelectedCharacter();
|
||||||
|
void openUserCharacterDirectory();
|
||||||
void reloadReminderList();
|
void reloadReminderList();
|
||||||
void reloadReminderSoundList(const QString &selectedSoundId = {});
|
void reloadReminderSoundList(const QString &selectedSoundId = {});
|
||||||
QString selectedReminderSoundId() const;
|
QString selectedReminderSoundId() const;
|
||||||
@@ -67,6 +87,16 @@ private:
|
|||||||
void importReminderSound();
|
void importReminderSound();
|
||||||
void deleteSelectedReminderSound();
|
void deleteSelectedReminderSound();
|
||||||
void testSelectedReminderSound();
|
void testSelectedReminderSound();
|
||||||
|
void testWeatherDefaultCity();
|
||||||
|
void refreshWebCapabilityStatus();
|
||||||
|
void testWebMode();
|
||||||
|
void reloadLaunchAppList();
|
||||||
|
void updateLaunchAppButtons();
|
||||||
|
QString selectedLaunchAppId() const;
|
||||||
|
void addLaunchApp();
|
||||||
|
void editSelectedLaunchApp();
|
||||||
|
void deleteSelectedLaunchApp();
|
||||||
|
void testSelectedLaunchApp();
|
||||||
|
|
||||||
QComboBox *m_providerComboBox = nullptr;
|
QComboBox *m_providerComboBox = nullptr;
|
||||||
QLineEdit *m_baseUrlEdit = nullptr;
|
QLineEdit *m_baseUrlEdit = nullptr;
|
||||||
@@ -79,6 +109,7 @@ private:
|
|||||||
QPushButton *m_testConnectionButton = nullptr;
|
QPushButton *m_testConnectionButton = nullptr;
|
||||||
QLabel *m_testStatusLabel = nullptr;
|
QLabel *m_testStatusLabel = nullptr;
|
||||||
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
|
QCheckBox *m_allowPlainApiKeyCheckBox = nullptr;
|
||||||
|
QCheckBox *m_launchAtStartupCheckBox = nullptr;
|
||||||
QSpinBox *m_scaleSpinBox = nullptr;
|
QSpinBox *m_scaleSpinBox = nullptr;
|
||||||
QComboBox *m_performanceModeComboBox = nullptr;
|
QComboBox *m_performanceModeComboBox = nullptr;
|
||||||
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
|
QCheckBox *m_pauseWhenHiddenCheckBox = nullptr;
|
||||||
@@ -92,9 +123,38 @@ private:
|
|||||||
QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr;
|
QSpinBox *m_savedHistoryMessageLimitSpinBox = nullptr;
|
||||||
QPushButton *m_clearConversationHistoryButton = nullptr;
|
QPushButton *m_clearConversationHistoryButton = nullptr;
|
||||||
QLabel *m_clearConversationStatusLabel = nullptr;
|
QLabel *m_clearConversationStatusLabel = nullptr;
|
||||||
|
QLineEdit *m_historySearchEdit = nullptr;
|
||||||
|
QComboBox *m_historyProviderFilterComboBox = nullptr;
|
||||||
|
QComboBox *m_historyModelFilterComboBox = nullptr;
|
||||||
|
QListWidget *m_historyListWidget = nullptr;
|
||||||
|
QPushButton *m_exportHistoryMarkdownButton = nullptr;
|
||||||
|
QPushButton *m_exportHistoryJsonButton = nullptr;
|
||||||
|
QLineEdit *m_weatherDefaultCityEdit = nullptr;
|
||||||
|
QCheckBox *m_weatherAutoLocateCheckBox = nullptr;
|
||||||
|
QPushButton *m_testWeatherDefaultCityButton = nullptr;
|
||||||
|
QLabel *m_weatherStatusLabel = nullptr;
|
||||||
|
QLabel *m_webCapabilityStatusLabel = nullptr;
|
||||||
|
QCheckBox *m_webEnabledCheckBox = nullptr;
|
||||||
|
QCheckBox *m_webRememberToggleCheckBox = nullptr;
|
||||||
|
QCheckBox *m_webDefaultOnCheckBox = nullptr;
|
||||||
|
QComboBox *m_webProviderModeComboBox = nullptr;
|
||||||
|
QSpinBox *m_webTimeoutSpinBox = nullptr;
|
||||||
|
QCheckBox *m_webShowCitationsCheckBox = nullptr;
|
||||||
|
QPushButton *m_testWebButton = nullptr;
|
||||||
|
QLabel *m_webStatusLabel = nullptr;
|
||||||
|
QCheckBox *m_launchEnabledCheckBox = nullptr;
|
||||||
|
QCheckBox *m_launchManualSelectionCheckBox = nullptr;
|
||||||
|
QListWidget *m_launchAppListWidget = nullptr;
|
||||||
|
QPushButton *m_addLaunchAppButton = nullptr;
|
||||||
|
QPushButton *m_editLaunchAppButton = nullptr;
|
||||||
|
QPushButton *m_deleteLaunchAppButton = nullptr;
|
||||||
|
QPushButton *m_testLaunchAppButton = nullptr;
|
||||||
|
QLabel *m_launchStatusLabel = nullptr;
|
||||||
QComboBox *m_characterComboBox = nullptr;
|
QComboBox *m_characterComboBox = nullptr;
|
||||||
QPushButton *m_importCharacterButton = nullptr;
|
QPushButton *m_importCharacterButton = nullptr;
|
||||||
QPushButton *m_deleteCharacterButton = nullptr;
|
QPushButton *m_deleteCharacterButton = nullptr;
|
||||||
|
QPushButton *m_exportCharacterButton = nullptr;
|
||||||
|
QPushButton *m_openUserCharacterDirButton = nullptr;
|
||||||
QLabel *m_characterStatusLabel = nullptr;
|
QLabel *m_characterStatusLabel = nullptr;
|
||||||
QComboBox *m_reminderStatusFilterComboBox = nullptr;
|
QComboBox *m_reminderStatusFilterComboBox = nullptr;
|
||||||
QListWidget *m_reminderListWidget = nullptr;
|
QListWidget *m_reminderListWidget = nullptr;
|
||||||
@@ -112,6 +172,10 @@ private:
|
|||||||
AIConfigStore m_configStore;
|
AIConfigStore m_configStore;
|
||||||
AIConfigStore m_acceptedConfigStore;
|
AIConfigStore m_acceptedConfigStore;
|
||||||
AppConfig m_appConfig;
|
AppConfig m_appConfig;
|
||||||
|
WeatherConfig m_weatherConfig;
|
||||||
|
WebConfig m_webConfig;
|
||||||
|
AppLaunchConfig m_appLaunchConfig;
|
||||||
|
QVector<ChatMessage> m_conversationHistory;
|
||||||
QVector<ReminderItem> m_reminders;
|
QVector<ReminderItem> m_reminders;
|
||||||
QString m_currentProvider;
|
QString m_currentProvider;
|
||||||
std::function<bool()> m_aiTestBlocked;
|
std::function<bool()> m_aiTestBlocked;
|
||||||
@@ -121,5 +185,8 @@ private:
|
|||||||
std::function<bool(QString *)> m_clearFinishedReminders;
|
std::function<bool(QString *)> m_clearFinishedReminders;
|
||||||
std::function<void(const QString &, double)> m_playReminderSound;
|
std::function<void(const QString &, double)> m_playReminderSound;
|
||||||
std::unique_ptr<LLMProvider> m_testProvider;
|
std::unique_ptr<LLMProvider> m_testProvider;
|
||||||
|
std::unique_ptr<AppLaunchManager> m_launchTestManager;
|
||||||
|
std::unique_ptr<WebChatManager> m_webTestManager;
|
||||||
|
std::unique_ptr<WeatherManager> m_weatherTestManager;
|
||||||
bool m_hasAcceptedConfigStore = false;
|
bool m_hasAcceptedConfigStore = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#include "WebStore.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 MinimumWebTimeoutMs = 3000;
|
||||||
|
constexpr int MaximumWebTimeoutMs = 120000;
|
||||||
|
const QString WebConfigFileName = QStringLiteral("web_config.json");
|
||||||
|
|
||||||
|
QString normalizedProviderMode(const QString &mode)
|
||||||
|
{
|
||||||
|
const QString normalized = mode.trimmed().toLower();
|
||||||
|
if (normalized == QStringLiteral("openai") || normalized == QStringLiteral("gemini"))
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return QStringLiteral("auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
WebConfig normalizedConfig(WebConfig config)
|
||||||
|
{
|
||||||
|
config.providerMode = normalizedProviderMode(config.providerMode);
|
||||||
|
config.timeoutMs = qBound(MinimumWebTimeoutMs, config.timeoutMs, MaximumWebTimeoutMs);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject objectFromConfig(const WebConfig &config)
|
||||||
|
{
|
||||||
|
const WebConfig normalized = normalizedConfig(config);
|
||||||
|
QJsonObject root;
|
||||||
|
root.insert(QStringLiteral("enabled"), normalized.enabled);
|
||||||
|
root.insert(QStringLiteral("rememberLastToggle"), normalized.rememberLastToggle);
|
||||||
|
root.insert(QStringLiteral("defaultToggleOn"), normalized.defaultToggleOn);
|
||||||
|
root.insert(QStringLiteral("lastToggleOn"), normalized.lastToggleOn);
|
||||||
|
root.insert(QStringLiteral("providerMode"), normalized.providerMode);
|
||||||
|
root.insert(QStringLiteral("timeoutMs"), normalized.timeoutMs);
|
||||||
|
root.insert(QStringLiteral("showCitations"), normalized.showCitations);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WebConfig WebStore::load(QString *errorMessage) const
|
||||||
|
{
|
||||||
|
WebConfig 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 web 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("Web config is broken; default config will be used."));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject root = document.object();
|
||||||
|
config.enabled = root.value(QStringLiteral("enabled")).toBool(config.enabled);
|
||||||
|
config.rememberLastToggle = root.value(QStringLiteral("rememberLastToggle")).toBool(config.rememberLastToggle);
|
||||||
|
config.defaultToggleOn = root.value(QStringLiteral("defaultToggleOn")).toBool(config.defaultToggleOn);
|
||||||
|
config.lastToggleOn = root.value(QStringLiteral("lastToggleOn")).toBool(config.lastToggleOn);
|
||||||
|
config.providerMode = root.value(QStringLiteral("providerMode")).toString(config.providerMode);
|
||||||
|
config.timeoutMs = root.value(QStringLiteral("timeoutMs")).toInt(config.timeoutMs);
|
||||||
|
config.showCitations = root.value(QStringLiteral("showCitations")).toBool(config.showCitations);
|
||||||
|
return normalizedConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WebStore::save(const WebConfig &config, QString *errorMessage) const
|
||||||
|
{
|
||||||
|
QDir directory(configDirectoryPath());
|
||||||
|
if (!directory.exists() && !directory.mkpath(QStringLiteral(".")))
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("无法创建联网模式配置目录。");
|
||||||
|
}
|
||||||
|
Logger::warning(QStringLiteral("Unable to create web 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 web 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("Web config write was incomplete."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.commit())
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = QStringLiteral("提交联网模式配置文件失败。");
|
||||||
|
}
|
||||||
|
Logger::warning(QStringLiteral("Unable to commit web config."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WebStore::storePath() const
|
||||||
|
{
|
||||||
|
return QDir(configDirectoryPath()).filePath(WebConfigFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString WebStore::configDirectoryPath() const
|
||||||
|
{
|
||||||
|
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||||
|
if (!path.isEmpty())
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QDir::currentPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebStore::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("web_config.broken.") + timestamp + QStringLiteral(".json"));
|
||||||
|
int suffix = 1;
|
||||||
|
while (QFile::exists(backupPath))
|
||||||
|
{
|
||||||
|
backupPath = fileInfo.dir().filePath(
|
||||||
|
QStringLiteral("web_config.broken.")
|
||||||
|
+ timestamp
|
||||||
|
+ QStringLiteral("-")
|
||||||
|
+ QString::number(suffix)
|
||||||
|
+ QStringLiteral(".json"));
|
||||||
|
++suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.rename(backupPath))
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Broken web config was backed up: %1").arg(backupPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::warning(QStringLiteral("Failed to back up broken web config: %1").arg(filePath));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "WebConfig.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class WebStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
WebConfig load(QString *errorMessage = nullptr) const;
|
||||||
|
bool save(const WebConfig &config, QString *errorMessage = nullptr) const;
|
||||||
|
QString storePath() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString configDirectoryPath() const;
|
||||||
|
void backupBrokenConfig(const QString &filePath) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OutputDir,
|
||||||
|
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Get-FullPath([string]$Path) {
|
||||||
|
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||||
|
return [System.IO.Path]::GetFullPath($Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.IO.Path]::GetFullPath((Join-Path (Get-Location) $Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Is-SubPath([string]$Candidate, [string]$Parent) {
|
||||||
|
$candidateFull = [System.IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/')
|
||||||
|
$parentFull = [System.IO.Path]::GetFullPath($Parent).TrimEnd('\', '/')
|
||||||
|
return $candidateFull.Equals($parentFull, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
$candidateFull.StartsWith($parentFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Should-ExcludeDirectory([string]$RelativePath) {
|
||||||
|
$segments = @($RelativePath -split '[\\/]' | Where-Object { $_ -ne "" })
|
||||||
|
if ($segments.Count -eq 0) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $segments[0].ToLowerInvariant()
|
||||||
|
if ($first -in @(
|
||||||
|
".git",
|
||||||
|
".vs",
|
||||||
|
".vscode",
|
||||||
|
".idea",
|
||||||
|
".agents",
|
||||||
|
".codex",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"release_packages",
|
||||||
|
"docs",
|
||||||
|
"reports",
|
||||||
|
"config",
|
||||||
|
"logs"
|
||||||
|
)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $first.StartsWith("cmake-build-")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Should-ExcludeFile([string]$RelativePath) {
|
||||||
|
$fileName = [System.IO.Path]::GetFileName($RelativePath).ToLowerInvariant()
|
||||||
|
if ($fileName -in @(
|
||||||
|
"readme.dev.md"
|
||||||
|
)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pattern in @(
|
||||||
|
"*.user",
|
||||||
|
"*.suo",
|
||||||
|
"*.vc.db",
|
||||||
|
"*.vc.opendb",
|
||||||
|
"*.autosave",
|
||||||
|
"*.broken.json",
|
||||||
|
"*.exe",
|
||||||
|
"*.dll",
|
||||||
|
"*.pdb",
|
||||||
|
"*.ilk",
|
||||||
|
"*.obj",
|
||||||
|
"*.o",
|
||||||
|
"*.a",
|
||||||
|
"*.lib"
|
||||||
|
)) {
|
||||||
|
if ($fileName -like $pattern) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Copy-PublicTree([string]$SourceDir, [string]$DestinationDir, [string]$RelativeDir) {
|
||||||
|
foreach ($item in Get-ChildItem -LiteralPath $SourceDir -Force) {
|
||||||
|
$relativePath = if ($RelativeDir) {
|
||||||
|
Join-Path $RelativeDir $item.Name
|
||||||
|
} else {
|
||||||
|
$item.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($item.PSIsContainer) {
|
||||||
|
if (Should-ExcludeDirectory $relativePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = Join-Path $DestinationDir $item.Name
|
||||||
|
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
|
||||||
|
Copy-PublicTree -SourceDir $item.FullName -DestinationDir $targetDir -RelativeDir $relativePath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Should-ExcludeFile $relativePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $DestinationDir $item.Name) -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
$outputPath = Get-FullPath $OutputDir
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
||||||
|
throw "OutputDir is required."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Is-SubPath -Candidate $outputPath -Parent $repoRoot) {
|
||||||
|
throw "OutputDir must be outside the repository to avoid recursive export: $outputPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $outputPath) {
|
||||||
|
$hasContent = (Get-ChildItem -LiteralPath $outputPath -Force | Select-Object -First 1) -ne $null
|
||||||
|
if ($hasContent -and -not $Force) {
|
||||||
|
throw "OutputDir already exists and is not empty. Pass -Force to replace it: $outputPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Force) {
|
||||||
|
Remove-Item -LiteralPath $outputPath -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
|
||||||
|
Copy-PublicTree -SourceDir $repoRoot -DestinationDir $outputPath -RelativeDir ""
|
||||||
|
|
||||||
|
Write-Host "GitHub export prepared:"
|
||||||
|
Write-Host " $outputPath"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Next steps:"
|
||||||
|
Write-Host " cd $outputPath"
|
||||||
|
Write-Host " git init"
|
||||||
|
Write-Host " git remote add origin https://github.com/Ysm-04/DesktopPet.git"
|
||||||
|
Write-Host " git add ."
|
||||||
|
Write-Host " git commit -m `"Publish GitHub version`""
|
||||||
|
Write-Host " git push -u origin main"
|
||||||
Reference in New Issue
Block a user