Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90262aaf6d | |||
| d0d1de4d00 |
@@ -25,4 +25,6 @@ cmake-build-*/
|
||||
/config/
|
||||
/logs/
|
||||
/reports/perf/
|
||||
/reports/qa/
|
||||
/internal/qa/out/
|
||||
*.broken.json
|
||||
|
||||
@@ -7,8 +7,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
set(CMAKE_AUTOMOC OFF)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
|
||||
|
||||
@@ -50,20 +48,6 @@ qt_add_executable(QtDesktopPet
|
||||
src/config/ConfigManager.cpp
|
||||
src/config/SecretStore.h
|
||||
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.cpp
|
||||
src/reminder/ReminderCommandHandler.h
|
||||
|
||||
+16
-63
@@ -40,9 +40,7 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
|
||||
- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知
|
||||
- 提醒音效切换、试听、用户 wav 导入和删除
|
||||
- 天气查询 v1:Open-Meteo 天气源、城市解析、默认城市、公网 IP 定位兜底和模板回复
|
||||
- 本地文件操作 v1:读取文本文件、列出文件夹、复制、备份、重命名
|
||||
- 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding
|
||||
- 应用启动 v1:通过聊天打开已登记应用、开始菜单快捷方式或用户确认选择的 `.exe`
|
||||
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
||||
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
|
||||
|
||||
@@ -50,9 +48,11 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
|
||||
|
||||
- 长期性能压测记录
|
||||
- 发布包实机安装/卸载验证
|
||||
- 文件操作 zip 打包、删除、覆盖、移动、脚本/命令执行
|
||||
- 搜索网页全文抓取、长期缓存和浏览器自动打开网页
|
||||
- 应用启动脚本、命令行参数、管理员权限和跨平台应用发现
|
||||
|
||||
当前非目标:
|
||||
|
||||
- 本地文件访问、应用启动、命令执行和工作区 Agent;这些能力已从当前产品方向移除
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -292,34 +292,6 @@ QStandardPaths::AppDataLocation/sounds/reminders/
|
||||
- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份
|
||||
- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除
|
||||
|
||||
## 本地文件操作
|
||||
|
||||
本地文件操作通过聊天意图触发,但不会直接使用聊天文本里的路径。所有文件和文件夹都必须由用户在系统文件选择框中主动选择。
|
||||
|
||||
当前 v1 支持:
|
||||
|
||||
- 读取用户选择的常见文本文件:txt、md、log、json、csv、ini、xml、yaml
|
||||
- 列出用户选择的文件夹,最多显示前 200 项
|
||||
- 复制用户选择的文件到用户选择的目标文件夹
|
||||
- 为用户选择的文件创建同目录备份
|
||||
- 重命名用户选择的文件
|
||||
|
||||
安全规则:
|
||||
|
||||
- 写操作会先展示操作计划并要求确认
|
||||
- 不覆盖已有文件
|
||||
- 不删除文件
|
||||
- 不移动大量文件
|
||||
- 不执行脚本或命令
|
||||
- 不访问 Windows 系统目录
|
||||
- 拒绝符号链接或符号链接目录内的路径
|
||||
|
||||
暂不支持:
|
||||
|
||||
- zip 打包
|
||||
- 删除、覆盖、移动
|
||||
- 修改源码
|
||||
- 执行脚本或命令
|
||||
|
||||
## 联网模式
|
||||
|
||||
@@ -329,6 +301,7 @@ QStandardPaths::AppDataLocation/sounds/reminders/
|
||||
- Google Gemini Provider:使用 Gemini Google Search grounding。
|
||||
- DeepSeek 官方 API:当前不提供托管联网搜索工具,显示“不支持原生联网”。
|
||||
- Custom / 第三方 OpenAI-Compatible:默认无法确认联网能力,不会尝试旧搜索页抓取。
|
||||
- 输入框“联网”开关在启用“记住输入框联网开关状态”时会随用户切换立即保存,不依赖发送消息。
|
||||
|
||||
配置保存到:
|
||||
|
||||
@@ -350,38 +323,12 @@ QStandardPaths::AppConfigLocation/web_config.json
|
||||
- 结构化搜索 API 或自建联网后端。
|
||||
- 更细粒度的来源证据摘录和引用 UI。
|
||||
|
||||
## 应用启动
|
||||
## 当前能力边界
|
||||
|
||||
应用启动是独立能力,不归入本地文件操作。可通过聊天输入:
|
||||
为保持桌宠产品聚焦,当前版本已移除聊天驱动的本地文件操作、应用启动和工作区 Agent。相关聊天内容只会进入普通 AI 对话或现有的提醒/天气意图,不会弹出本地路径选择器、启动程序、扫描项目或执行命令。
|
||||
|
||||
```text
|
||||
打开 Codex
|
||||
启动酷狗音乐
|
||||
帮我打开 VSCode
|
||||
```
|
||||
应用自身的“开机自启动”仍保留,由设置页和安装器选项管理。旧版 `launcher_config.json` 与 `app_config.json` 中的 `agent` 字段不会再读取或写入,但程序不会主动删除用户目录中的旧文件。
|
||||
|
||||
配置保存到:
|
||||
|
||||
```text
|
||||
QStandardPaths::AppConfigLocation/launcher_config.json
|
||||
```
|
||||
|
||||
设置页“应用启动”支持:
|
||||
|
||||
- 启用或关闭应用启动
|
||||
- 未知应用允许用户手动选择 `.exe`
|
||||
- 登记应用名称和别名
|
||||
- 编辑、删除、测试启动已登记应用
|
||||
|
||||
安全边界:
|
||||
|
||||
- 启动前始终二次确认,确认内容包含应用名、路径、工作目录和来源
|
||||
- 只允许启动 `.exe` 或开始菜单 `.lnk` 快捷方式
|
||||
- 不执行 `.bat` / `.cmd` / `.ps1` / `.vbs` / `.js` / `.msi`
|
||||
- 不执行聊天文本里的命令
|
||||
- 不拼接聊天文本参数
|
||||
- 不以管理员权限启动
|
||||
- 手选 `.exe` 后可选择记住为当前名称,后续走登记应用匹配
|
||||
|
||||
## 配置和日志
|
||||
|
||||
@@ -399,7 +346,6 @@ 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
|
||||
```
|
||||
|
||||
日志输出到文件,不输出到控制台:
|
||||
@@ -512,7 +458,7 @@ C:\Program Files (x86)\Inno Setup 6\ISCC.exe
|
||||
仓库提供开发用性能采样脚本,不进入普通用户发布包:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m
|
||||
```
|
||||
|
||||
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到:
|
||||
@@ -527,6 +473,13 @@ reports/perf/
|
||||
docs/performance_stability_check.md
|
||||
```
|
||||
|
||||
内部核心逻辑测试、静态检查、性能报告和长期测试记录说明见:
|
||||
|
||||
```text
|
||||
internal/qa/README.md
|
||||
docs/test-records/README.md
|
||||
```
|
||||
|
||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。
|
||||
|
||||
## AI 配置和聊天
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QtDesktopPet
|
||||
|
||||
一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、本地提醒、天气查询、简单文件操作和本地应用启动等能力。
|
||||
一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、AI 原生联网、本地提醒和天气查询等能力。
|
||||
|
||||
> 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。
|
||||
|
||||
@@ -31,22 +31,13 @@
|
||||
- Open-Meteo Forecast API
|
||||
- Open-Meteo Geocoding API
|
||||
- 默认城市、公网 IP 定位兜底、多候选提示
|
||||
- 本地文件操作 v1:
|
||||
- 读取文本文件
|
||||
- 列出文件夹
|
||||
- 复制、备份、重命名
|
||||
- 写操作前二次确认
|
||||
- 应用启动 v1:
|
||||
- 聊天触发打开本地应用
|
||||
- 支持已登记应用、开始菜单快捷方式和用户手选 `.exe`
|
||||
- 启动前二次确认
|
||||
- Windows 发布脚本和 Inno Setup 安装器脚本。
|
||||
|
||||
## Platform
|
||||
|
||||
当前主要目标平台是 Windows 10 / Windows 11。
|
||||
|
||||
项目中已有部分跨平台基础代码,但托盘通知、开机自启动、应用发现和安装器体验目前按 Windows 优先实现。
|
||||
项目中已有部分跨平台基础代码,但托盘通知、开机自启动和安装器体验目前按 Windows 优先实现。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -75,8 +66,6 @@
|
||||
│ ├── 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
|
||||
@@ -86,7 +75,7 @@
|
||||
│ ├── util/
|
||||
│ ├── weather/
|
||||
│ └── web/ # AI-native web mode
|
||||
└── tools/ # Packaging and diagnostic scripts
|
||||
└── tools/ # Packaging and public-export scripts
|
||||
```
|
||||
|
||||
## Build
|
||||
@@ -146,7 +135,6 @@ Examples:
|
||||
- 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:
|
||||
|
||||
@@ -168,14 +156,10 @@ Important notes:
|
||||
|
||||
## 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.
|
||||
- The application does not read, search, create, modify, move, or delete local files through chat.
|
||||
- It does not launch local applications or convert chat text into shell commands or scripts.
|
||||
- Windows startup integration only manages this application's current-user `Run` entry.
|
||||
- AI and web requests are sent only to providers configured by the user.
|
||||
|
||||
## Web Mode
|
||||
|
||||
|
||||
+47
-1191
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。
|
||||
|
||||
每次实际执行结果保存到 `docs/test-records/`,不得直接用某次结果覆盖本验收标准。内部自动化入口见 `internal/qa/README.md`。
|
||||
|
||||
## 验收记录格式
|
||||
|
||||
每个用例建议记录:
|
||||
@@ -24,7 +26,7 @@ Qt 版本:
|
||||
- 日志不包含完整 API Key、Authorization、完整用户消息正文、完整错误响应正文或完整搜索词。
|
||||
- 设置页保存后立即生效,重启后配置仍能读取。
|
||||
- 所有损坏 JSON 配置都应备份为 `.broken.yyyyMMdd-HHmmss.json` 后回退默认或忽略损坏数据。
|
||||
- 所有涉及本地文件写入的操作必须由用户选择路径,并在执行前显示计划和二次确认。
|
||||
- 角色导入/导出、音效导入和历史导出只能使用用户明确选择的路径;覆盖或删除用户数据时必须确认。
|
||||
- 不运行 CMake 的自动验证;构建和运行由用户手动完成。
|
||||
|
||||
## 基础窗口与托盘
|
||||
@@ -37,6 +39,8 @@ Qt 版本:
|
||||
| 隐藏托盘 | 托盘菜单隐藏/显示 | 隐藏后动画暂停,显示后恢复 | TODO |
|
||||
| 单实例 | 已运行时再次启动 exe | 只有一个进程,已有实例被唤醒 | TODO |
|
||||
| 设置页居中 | 多屏或重复打开设置页 | 设置页出现在当前屏幕可用区域内并置前 | TODO |
|
||||
| 应用设置持久化 | 修改缩放、性能模式、隐藏暂停、懒加载和缓存设置后重启 | 设置立即生效,重启后保持,动画和窗口尺寸无异常 | TODO |
|
||||
| 开机自启动 | 在应用页依次开启、重启检查、再关闭 | 开启时写入当前用户 Run 项且路径正确;关闭时移除;默认关闭 | TODO |
|
||||
|
||||
## 角色管理
|
||||
|
||||
@@ -59,7 +63,7 @@ Qt 版本:
|
||||
| 连接测试 | 点击测试连接 | 成功显示成功;错误配置显示明确错误 | TODO |
|
||||
| 普通聊天 | 发送短消息 | `think` 后进入 `talk`,气泡显示回复 | TODO |
|
||||
| 流式回复 | 启用流式并发送消息 | 首段前保持 `think`,输出中保持 `talk` | TODO |
|
||||
| AI 忙 | 回复中再次发送聊天/天气/搜索 | 不启动第二个请求,显示忙提示 | TODO |
|
||||
| AI 忙 | 回复中再次发送聊天/天气/联网请求 | 不启动第二个请求,显示忙提示 | TODO |
|
||||
| 取消 AI | 右键取消 AI 请求 | 请求取消,状态恢复,气泡提示取消 | TODO |
|
||||
| 历史保存 | 开启本地保存后聊天 | 重启后历史可读取 | TODO |
|
||||
| 历史搜索 | 在设置页按关键词搜索 | 只显示匹配记录 | TODO |
|
||||
@@ -67,6 +71,7 @@ Qt 版本:
|
||||
| 导出 Markdown | 导出筛选结果为 `.md` | 文件包含角色、时间、Provider/模型和正文,不含 API Key | TODO |
|
||||
| 导出 JSON | 导出筛选结果为 `.json` | JSON 可解析,包含 messages 数组,不含 API Key | TODO |
|
||||
| 清空历史 | 点击清空聊天记录并确认 | 内存和本地历史清空,面板刷新 | TODO |
|
||||
| 关闭历史保存 | 关闭本地保存后聊天并重启 | 本轮消息只在内存存在,重启后不写入本地历史 | TODO |
|
||||
|
||||
## 定时提醒
|
||||
|
||||
@@ -90,6 +95,8 @@ Qt 版本:
|
||||
| 历史清理 | 构造 21 天前 triggered/canceled 后清理 | 删除 20 天前历史,保留最近 20 天和 pending | TODO |
|
||||
| 音效导入 | 导入合法 PCM wav | 可选择、试听、删除 | TODO |
|
||||
| 内置音效保护 | 选择内置音效点击删除 | 删除按钮禁用或提示不可删除 | TODO |
|
||||
| 睡眠与时间变化 | 睡眠跨过提醒时间;再分别模拟系统时间向前和向后变化 | 唤醒或向前跳变后补扫一次;向后跳变不提前触发 | TODO |
|
||||
| 提醒保存失败 | 在隔离测试数据上模拟提醒文件不可写并等待到期 | 保持 pending,不播放、不通知、不显示气泡,后续仍可重试 | TODO |
|
||||
|
||||
## 天气查询
|
||||
|
||||
@@ -106,21 +113,20 @@ Qt 版本:
|
||||
| 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO |
|
||||
| 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO |
|
||||
| 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO |
|
||||
| 天气网络失败 | 断网或阻断天气请求后查询 | 明确提示网络/超时错误,UI 不阻塞,恢复网络后可再次查询 | 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 |
|
||||
| 文件操作已移除 | 输入 `读取文件`、`创建 test.txt`、`复制文件` | 不弹文件选择框,不读取或修改本地文件;按普通 AI 对话处理 | TODO |
|
||||
| 应用启动已移除 | 输入 `打开 Codex`、`启动酷狗音乐` | 不弹 exe 选择框或确认框,不启动任何程序;按普通 AI 对话处理 | TODO |
|
||||
| 工作区 Agent 已移除 | 输入 `打开工作区`、`分析这个项目`、`含 class 的文件` | 不扫描目录、不弹 Agent 窗口、不访问本地项目;按普通 AI 对话处理 | TODO |
|
||||
| 菜单入口清理 | 检查桌宠右键菜单和托盘菜单 | 不再出现 `Agent 工作区` 入口 | TODO |
|
||||
| 设置页清理 | 打开设置页 | 不再出现 `应用启动` 和 `Agent` 页面;应用页仍有 `开机自启动` | TODO |
|
||||
| 快捷键清理 | 按 `Alt+A` | 不再呼出 Agent 窗口,不影响其他快捷键或输入 | TODO |
|
||||
| 旧配置兼容 | 用户目录保留旧 `launcher_config.json`,`app_config.json` 保留 `agent` 对象后启动 | 程序不崩溃,不读取旧能力配置;保存应用配置后不再写入 `agent` | TODO |
|
||||
|
||||
|
||||
## 联网模式
|
||||
|
||||
@@ -137,23 +143,34 @@ Qt 版本:
|
||||
| 设置页状态 | 切换 OpenAI/Gemini/DeepSeek/Custom | “联网模式”页即时显示可用/不可用/无法确认状态 | TODO |
|
||||
| 设置页测试 | 点击“测试联网模式” | 支持 Provider 发起测试,不支持 Provider 直接显示原因 | TODO |
|
||||
| 旧失败回归 | 打开联网后询问 `美国的首都在哪里` | 不再返回搜狗/360 帮助、登录、反馈页 | TODO |
|
||||
| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 仍优先走提醒或文件操作,不受联网开关影响 | TODO |
|
||||
| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 前者走提醒;后者可走天气或普通对话,但绝不触发本地文件保存 | TODO |
|
||||
| 联网开关双向记忆 | 启用记忆后分别执行关到开、开到关,不发送消息直接关闭并重开输入框 | 两个方向都保存最后状态;关闭记忆后使用默认开关值 | TODO |
|
||||
| 联网网络失败 | 支持 Provider 下模拟断网、超时或服务端错误 | 明确提示失败,状态恢复,可重新发起请求,不泄露完整错误正文 | 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 |
|
||||
| App 配置损坏 | 写入非法 `app_config.json` 后启动 | 原文件备份为带时间戳的 broken 文件,程序使用默认配置启动 | TODO |
|
||||
| AI 配置损坏 | 写入非法 `ai_config.json` 后启动 | 备份损坏文件,AI 回退未配置状态,不泄露旧 Key | TODO |
|
||||
| 天气配置损坏 | 写入非法 `weather_config.json` 后启动 | 备份损坏文件,天气使用默认配置 | TODO |
|
||||
| 联网配置损坏 | 写入非法 `web_config.json` 后启动 | 备份损坏文件,联网开关使用默认配置 | TODO |
|
||||
| 提醒数据损坏 | 写入非法 `reminders.json` 后启动 | 备份损坏文件,不崩溃、不触发错误数据 | TODO |
|
||||
| 历史数据损坏 | 写入非法 `conversation_history.json` 后打开历史 | 备份损坏文件并忽略,历史面板可继续使用 | TODO |
|
||||
| 日志隐私 | 覆盖正常请求、错误 Key、错误 Base URL、天气和联网失败 | 日志不含 API Key、Authorization、完整消息或完整错误响应 | TODO |
|
||||
|
||||
## 自动化验证
|
||||
|
||||
内部 QA 与发布构建隔离。用户配置好 Qt 测试构建后执行核心逻辑测试;不需要编译的静态检查可直接执行:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1
|
||||
```
|
||||
|
||||
当前自动化覆盖意图分发、提醒解析和类型、天气解析和模板、联网能力检测、聊天历史兼容,以及源码登记、废弃模块残留、默认资源和 GitHub 导出隔离。真实窗口、托盘通知、音效、网络 Provider、安装卸载仍按手测执行。
|
||||
|
||||
|
||||
## 发布与性能
|
||||
|
||||
@@ -192,7 +209,6 @@ UI 状态:
|
||||
| 提醒 | `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 或构建命令 |
|
||||
| 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 |
|
||||
| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 |
|
||||
| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;性能文档覆盖静置、AI、提醒、天气和联网模式 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 |
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# QtDesktopPet 文档索引
|
||||
|
||||
本文档用于区分当前有效文档和历史归档文档,避免后续开发继续引用过时路线。
|
||||
|
||||
## 当前有效文档
|
||||
|
||||
| 文档 | 用途 |
|
||||
| --- | --- |
|
||||
| `QtDesktopPet_后续功能规划与结构审查.md` | 当前功能状态、下一阶段路线和结构边界 |
|
||||
| `QtDesktopPet_测试清单与验收标准.md` | 功能测试清单、回归验收标准和静态推演记录 |
|
||||
| `performance_stability_check.md` | 性能采样、稳定性检查和发布验证入口 |
|
||||
| `test-records/` | 每次回归、性能和候选发布的长期测试记录 |
|
||||
|
||||
## 归档文档
|
||||
|
||||
历史文档统一放在 `docs/archive/`。这些文档保留为开发上下文,不再作为当前施工依据。
|
||||
|
||||
| 文档 | 归档原因 |
|
||||
| --- | --- |
|
||||
| `archive/implementation_plan.md` | 早期分阶段实施计划,当前大部分阶段已完成或被后续模块替代 |
|
||||
| `archive/Qt_DesktopPet_开发文档.md` | 初版开发总纲,当前产品能力已超出第一版范围 |
|
||||
| `archive/QtDesktopPet_后续功能规划与结构审查_旧版.md` | 旧后续规划过长,包含大量已完成阶段和旧文件操作路线 |
|
||||
| `archive/DesktopPet 本地工作区 Agent 开发文档_草案.md` | 已终止的本地工作区 Agent 初稿 |
|
||||
| `archive/DesktopPet_本地工作区Agent_施工计划_已终止.md` | 工作区 Agent 路线已从当前产品移除 |
|
||||
| `archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md` | 独立 Agent 窗口路线已从当前产品移除 |
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 新功能进入开发前,先更新当前路线文档和对应测试清单。
|
||||
- 功能完成后,同步更新 README、测试清单和性能检查项。
|
||||
- 历史文档不再直接修改,除非补充归档说明。
|
||||
- GitHub 公开导出不包含 `docs/`;开发仓库保留完整文档。
|
||||
- 内部自动化测试位于 `internal/qa/`,只进入开发仓库,不进入 GitHub 公开导出和发布包。
|
||||
- 在构建卡住问题解决前,文档中的验证步骤仍以用户手动构建为准,不由 Codex 调用 CMake 或构建命令。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,871 @@
|
||||
# DesktopPet 本地工作区 Agent 施工计划
|
||||
|
||||
> 归档说明:该路线已于 2026-07-16 从 QtDesktopPet 当前产品方向中移除,本文仅保留历史设计,不再作为施工依据。
|
||||
|
||||
## 1. 定位
|
||||
|
||||
本地工作区 Agent 是下一阶段核心功能。它不是继续维护旧版“文件操作 v1”,而是新增一个独立的工作区系统:
|
||||
|
||||
> 用户选择一个本地工作区目录后,桌宠可以读取、搜索、分析该目录内的文件,并在用户确认后创建、修改、复制、移动、重命名或删除到回收站。
|
||||
|
||||
第一阶段优先实现文件级工具,不做 Shell 命令执行,不做脚本执行,不做自动安装依赖。
|
||||
|
||||
## 2. 总原则
|
||||
|
||||
- 不允许 AI 直接获得全盘文件权限。
|
||||
- 不允许 AI 自由执行系统命令。
|
||||
- 不允许永久删除文件。
|
||||
- 不允许绕过用户确认修改本地文件。
|
||||
- 不允许默认读取敏感文件。
|
||||
- 所有写操作必须先生成计划,展示路径、动作和风险,用户确认后才能执行。
|
||||
- 所有文件路径必须限制在用户选择的工作区内。
|
||||
- `PetWindow` 只负责 UI 入口、展示和确认,不承载复杂业务逻辑。
|
||||
- 新模块先新增、后替换旧 `src/fileops/`。
|
||||
|
||||
## 3. 分期目标
|
||||
|
||||
当前执行状态:
|
||||
|
||||
- v1a.1/v1b/v1b.5 已落地到 `src/workspace/` 和 `src/ui/WorkspaceAgentWindow.*`。
|
||||
- 当前不持久化工作区路径,不执行写操作,不替换旧 `src/fileops/`。
|
||||
- v1b 已支持打开工作区内普通文件、打开文件夹、显示文件所在位置。
|
||||
- v1b.5 已提供独立 Agent 工作区窗口、右键/托盘入口和默认 `Alt+A` 呼出快捷键;窗口包含左侧结果导航、中间任务对话、右侧详情/上下文和 v1c 预留变更区。
|
||||
|
||||
### v1a:只读工作区
|
||||
|
||||
目标:
|
||||
|
||||
- 选择工作区。
|
||||
- 显示当前工作区。
|
||||
- 扫描工作区文件树。
|
||||
- 搜索文件名。
|
||||
- 搜索文本内容。
|
||||
- 读取文本文件。
|
||||
- 分析项目结构。
|
||||
- 总结文件内容。
|
||||
|
||||
验收:
|
||||
|
||||
- 工作区外路径被拒绝。
|
||||
- 系统目录被拒绝。
|
||||
- `.git/build/node_modules/dist/release_packages` 等目录被跳过。
|
||||
- 敏感文件默认拒绝读取。
|
||||
- 二进制文件拒绝读取。
|
||||
|
||||
### v1b:打开和定位
|
||||
|
||||
状态:已落地。
|
||||
|
||||
目标:
|
||||
|
||||
- 打开工作区内普通文件。
|
||||
- 打开工作区内文件夹。
|
||||
- 显示文件所在位置。
|
||||
|
||||
验收:
|
||||
|
||||
- 危险后缀拒绝打开。
|
||||
- `.lnk` 不直接打开。
|
||||
- 工作区外路径拒绝。
|
||||
- 打开文件必须二次确认,打开文件夹作为低风险操作直接执行。
|
||||
|
||||
### v1c:低风险写入
|
||||
|
||||
进入 v1c 前已完成:
|
||||
|
||||
- `创建 test.txt / 新建文件 / 写入 README / 修改 main.cpp` 明确拒绝,不再误进入旧 fileops 读取文件流程。
|
||||
- 独立 Agent 窗口可以分区展示上一轮结果、文本命中、上下文、选中项详情和最近读取文件。
|
||||
- 窗口支持对选中结果执行读取、打开和显示位置。
|
||||
- 窗口可通过桌宠右键菜单、托盘菜单和可配置全局快捷键呼出。
|
||||
|
||||
目标:
|
||||
|
||||
- 创建文本文件。
|
||||
- 创建文件夹。
|
||||
- 复制单个文件。
|
||||
- 同目录重命名文件。
|
||||
|
||||
验收:
|
||||
|
||||
- 所有写操作必须确认。
|
||||
- 目标文件或目录已存在时拒绝覆盖。
|
||||
- 文件名非法时拒绝。
|
||||
- 危险后缀拒绝创建或写入。
|
||||
|
||||
### v1d:高风险写入
|
||||
|
||||
目标:
|
||||
|
||||
- 修改已有文本文件。
|
||||
- 移动单个文件。
|
||||
- 删除文件到回收站。
|
||||
- 删除文件夹到回收站。
|
||||
|
||||
验收:
|
||||
|
||||
- 修改前必须备份。
|
||||
- 移动和删除必须强确认。
|
||||
- 删除只进入回收站。
|
||||
- 批量删除第一阶段拒绝。
|
||||
- 失败时不能破坏原文件。
|
||||
|
||||
### v1e:替换旧文件操作
|
||||
|
||||
目标:
|
||||
|
||||
- `FileOperation` 意图迁移为 `Workspace`。
|
||||
- `PetWindow::handleFileOperationChatMessage()` 过渡为调用 `WorkspaceController`。
|
||||
- 新模块覆盖旧能力后删除旧 `src/fileops/`。
|
||||
|
||||
验收:
|
||||
|
||||
- 无 `FileOperationManager` 有效引用。
|
||||
- CMake 不再登记旧 fileops 文件。
|
||||
- README 和测试文档改为“本地工作区 Agent”。
|
||||
|
||||
## 4. 新目录结构
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
src/workspace/
|
||||
├── WorkspaceTypes.h
|
||||
├── WorkspaceSession.h
|
||||
├── WorkspaceSession.cpp
|
||||
├── WorkspaceIndex.h
|
||||
├── WorkspaceIndex.cpp
|
||||
├── WorkspaceScanner.h
|
||||
├── WorkspaceScanner.cpp
|
||||
├── WorkspaceAccessPolicy.h
|
||||
├── WorkspaceAccessPolicy.cpp
|
||||
├── WorkspaceTool.h
|
||||
├── WorkspaceToolRegistry.h
|
||||
├── WorkspaceToolRegistry.cpp
|
||||
├── WorkspaceAgent.h
|
||||
├── WorkspaceAgent.cpp
|
||||
├── WorkspaceController.h
|
||||
├── WorkspaceController.cpp
|
||||
├── WorkspaceHistory.h
|
||||
├── WorkspaceHistory.cpp
|
||||
└── tools/
|
||||
├── ListFilesTool.h
|
||||
├── ListFilesTool.cpp
|
||||
├── ReadFileTool.h
|
||||
├── ReadFileTool.cpp
|
||||
├── SearchFilesTool.h
|
||||
├── SearchFilesTool.cpp
|
||||
├── SearchTextTool.h
|
||||
├── SearchTextTool.cpp
|
||||
├── OpenFileTool.h
|
||||
├── OpenFileTool.cpp
|
||||
├── CreateFileTool.h
|
||||
├── CreateFileTool.cpp
|
||||
├── CreateDirectoryTool.h
|
||||
├── CreateDirectoryTool.cpp
|
||||
├── WriteFileTool.h
|
||||
├── WriteFileTool.cpp
|
||||
├── CopyFileTool.h
|
||||
├── CopyFileTool.cpp
|
||||
├── MoveFileTool.h
|
||||
├── MoveFileTool.cpp
|
||||
├── RenameFileTool.h
|
||||
├── RenameFileTool.cpp
|
||||
├── DeleteToTrashTool.h
|
||||
└── DeleteToTrashTool.cpp
|
||||
```
|
||||
|
||||
第一阶段继续保持 `CMAKE_AUTOMOC OFF`,新增业务类不使用 `Q_OBJECT`。
|
||||
|
||||
## 5. 核心类型
|
||||
|
||||
```cpp
|
||||
enum class WorkspacePermissionMode
|
||||
{
|
||||
ReadOnly,
|
||||
ConfirmBeforeWrite,
|
||||
Advanced
|
||||
};
|
||||
```
|
||||
|
||||
第一阶段默认 `ConfirmBeforeWrite`,但所有写操作仍必须确认。`Advanced` 只预留,不启用自动写入。
|
||||
|
||||
```cpp
|
||||
enum class WorkspaceToolRisk
|
||||
{
|
||||
ReadOnly,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Dangerous
|
||||
};
|
||||
```
|
||||
|
||||
风险定义:
|
||||
|
||||
- `ReadOnly`:列目录、搜索、读取普通文本文件。
|
||||
- `Low`:打开文件夹、显示文件所在位置。
|
||||
- `Medium`:打开普通文件、创建文件、创建文件夹、复制文件。
|
||||
- `High`:修改文件、移动文件、重命名文件、删除到回收站。
|
||||
- `Dangerous`:永久删除、执行命令、运行脚本、修改系统目录。
|
||||
|
||||
第一阶段遇到 `Dangerous` 直接拒绝。
|
||||
|
||||
```cpp
|
||||
struct WorkspaceFileInfo
|
||||
{
|
||||
QString relativePath;
|
||||
QString absolutePath;
|
||||
QString fileName;
|
||||
QString suffix;
|
||||
qint64 sizeBytes = 0;
|
||||
QDateTime lastModified;
|
||||
bool isDirectory = false;
|
||||
bool isTextFile = false;
|
||||
bool isHidden = false;
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
enum class WorkspaceActionType
|
||||
{
|
||||
Unknown,
|
||||
ListFiles,
|
||||
SearchFiles,
|
||||
SearchText,
|
||||
ReadFile,
|
||||
OpenFile,
|
||||
OpenDirectory,
|
||||
RevealInExplorer,
|
||||
CreateFile,
|
||||
CreateDirectory,
|
||||
WriteFile,
|
||||
CopyFile,
|
||||
MoveFile,
|
||||
RenameFile,
|
||||
DeleteToTrash
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
struct WorkspaceActionPlan
|
||||
{
|
||||
QString id;
|
||||
WorkspaceActionType type = WorkspaceActionType::Unknown;
|
||||
WorkspaceToolRisk risk = WorkspaceToolRisk::ReadOnly;
|
||||
QString title;
|
||||
QString description;
|
||||
QString sourcePath;
|
||||
QString targetPath;
|
||||
QString relativeSourcePath;
|
||||
QString relativeTargetPath;
|
||||
QString content;
|
||||
QString searchKeyword;
|
||||
QStringList warnings;
|
||||
bool requiresConfirmation = false;
|
||||
bool allowOverwrite = false;
|
||||
bool confirmed = false;
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
struct WorkspaceToolResult
|
||||
{
|
||||
bool success = false;
|
||||
QString message;
|
||||
QString errorMessage;
|
||||
QString outputText;
|
||||
QString targetPath;
|
||||
QVector<WorkspaceFileInfo> files;
|
||||
};
|
||||
```
|
||||
|
||||
## 6. 安全策略
|
||||
|
||||
`WorkspaceAccessPolicy` 是最重要的模块,所有工具必须经过它校验。
|
||||
|
||||
职责:
|
||||
|
||||
- 路径标准化。
|
||||
- 判断路径是否在工作区内。
|
||||
- 阻止 `../` 跳出工作区。
|
||||
- 阻止符号链接跳出工作区。
|
||||
- 阻止系统目录。
|
||||
- 阻止危险文件类型。
|
||||
- 阻止敏感文件默认读取。
|
||||
- 限制写入行为。
|
||||
- 限制删除行为。
|
||||
|
||||
建议接口:
|
||||
|
||||
```cpp
|
||||
class WorkspaceAccessPolicy
|
||||
{
|
||||
public:
|
||||
explicit WorkspaceAccessPolicy(const QString &workspaceRoot);
|
||||
|
||||
QString workspaceRoot() const;
|
||||
QString normalizePath(const QString &path) const;
|
||||
QString toAbsolutePath(const QString &relativeOrAbsolutePath) const;
|
||||
QString toRelativePath(const QString &absolutePath) const;
|
||||
|
||||
bool isInsideWorkspace(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool hasSymlinkSegment(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool isSystemPath(const QString &path) const;
|
||||
bool isDangerousSuffix(const QString &path) const;
|
||||
bool isSensitiveFile(const QString &path) const;
|
||||
bool isSupportedTextFile(const QString &path) const;
|
||||
|
||||
bool canReadFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canReadDirectory(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canCreateFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canCreateDirectory(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canWriteFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canMoveFile(const QString &sourcePath, const QString &targetPath, QString *errorMessage = nullptr) const;
|
||||
bool canDeleteToTrash(const QString &path, QString *errorMessage = nullptr) const;
|
||||
};
|
||||
```
|
||||
|
||||
禁止危险后缀:
|
||||
|
||||
```text
|
||||
.exe .bat .cmd .ps1 .vbs .msi .scr .reg .com .pif .lnk
|
||||
```
|
||||
|
||||
敏感文件默认拒绝:
|
||||
|
||||
```text
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa
|
||||
id_ed25519
|
||||
*.pfx
|
||||
*.p12
|
||||
credentials.json
|
||||
secrets.json
|
||||
token.json
|
||||
```
|
||||
|
||||
文件名包含以下关键词也视为敏感:
|
||||
|
||||
```text
|
||||
secret
|
||||
token
|
||||
password
|
||||
credential
|
||||
private
|
||||
apikey
|
||||
api_key
|
||||
```
|
||||
|
||||
## 7. 扫描和索引
|
||||
|
||||
`WorkspaceScanner` 第一版使用 `QDir` + 手动目录栈扫描工作区,后续如需取消、进度回调或更细粒度过滤,再考虑替换为 `QDirIterator` 或异步扫描。
|
||||
|
||||
默认限制:
|
||||
|
||||
- 最大递归深度:8。
|
||||
- 最大文件数量:5000。
|
||||
- 跳过隐藏目录。
|
||||
- 跳过常见产物目录。
|
||||
|
||||
跳过目录:
|
||||
|
||||
```text
|
||||
.git
|
||||
.vs
|
||||
.idea
|
||||
build
|
||||
cmake-build-debug
|
||||
cmake-build-release
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
release_packages
|
||||
logs
|
||||
```
|
||||
|
||||
`WorkspaceIndex` 第一版只做内存索引:
|
||||
|
||||
```cpp
|
||||
class WorkspaceIndex
|
||||
{
|
||||
public:
|
||||
void clear();
|
||||
void setFiles(const QVector<WorkspaceFileInfo> &files);
|
||||
QVector<WorkspaceFileInfo> allFiles() const;
|
||||
QVector<WorkspaceFileInfo> findByName(const QString &keyword, int maxResults) const;
|
||||
QVector<WorkspaceFileInfo> findBySuffix(const QStringList &suffixes, int maxResults) const;
|
||||
QVector<WorkspaceFileInfo> recentFiles(int maxResults) const;
|
||||
};
|
||||
```
|
||||
|
||||
## 8. 工具清单
|
||||
|
||||
### 8.1 只读工具
|
||||
|
||||
`ListFilesTool`
|
||||
|
||||
- 列出工作区内目录。
|
||||
- 最多显示 200 项。
|
||||
- 不递归或浅递归。
|
||||
|
||||
`ReadFileTool`
|
||||
|
||||
- 只读文本文件。
|
||||
- 最大读取 128KB。
|
||||
- 超出截断。
|
||||
- 敏感文件默认拒绝。
|
||||
- 不读二进制文件。
|
||||
|
||||
支持后缀:
|
||||
|
||||
```text
|
||||
.cpp .h .hpp .c .cc .py .js .ts .json .md .txt .ini .xml .yaml .yml .cmake .pro .css .html
|
||||
```
|
||||
|
||||
`SearchFilesTool`
|
||||
|
||||
- 只搜索当前工作区索引。
|
||||
- 最多返回 30 个结果。
|
||||
- 按相关性和最近修改时间排序。
|
||||
|
||||
`SearchTextTool`
|
||||
|
||||
- 最多扫描 1000 个文本文件。
|
||||
- 单文件最大读取 128KB。
|
||||
- 最多返回 50 条匹配。
|
||||
- 每条显示文件路径、行号、片段。
|
||||
|
||||
### 8.2 打开工具
|
||||
|
||||
`OpenFileTool`
|
||||
|
||||
- 用系统默认程序打开文件。
|
||||
- 只能打开工作区内普通文件。
|
||||
- 危险后缀拒绝。
|
||||
- 敏感文件需要确认或第一版拒绝。
|
||||
|
||||
`OpenDirectoryTool`
|
||||
|
||||
- 打开工作区内目录。
|
||||
- 工作区外路径拒绝。
|
||||
|
||||
`RevealInExplorerTool`
|
||||
|
||||
- 第一版可以先打开所在文件夹。
|
||||
- 如果后续使用 `explorer.exe /select`,必须固定程序路径和参数,不接受聊天文本命令。
|
||||
|
||||
### 8.3 写入工具
|
||||
|
||||
`CreateFileTool`
|
||||
|
||||
- 只能在工作区内创建。
|
||||
- 目标不能已存在。
|
||||
- 文件名必须合法。
|
||||
- 危险后缀拒绝。
|
||||
- 必须确认。
|
||||
|
||||
`CreateDirectoryTool`
|
||||
|
||||
- 只能在工作区内创建。
|
||||
- 目标不能已存在。
|
||||
- 必须确认。
|
||||
|
||||
`WriteFileTool`
|
||||
|
||||
- 修改已有文本文件前自动备份。
|
||||
- 禁止覆盖二进制文件。
|
||||
- 禁止写敏感文件。
|
||||
- 禁止写危险后缀。
|
||||
- 必须确认。
|
||||
|
||||
备份目录:
|
||||
|
||||
```text
|
||||
<workspaceRoot>/.desktoppet/backups/
|
||||
```
|
||||
|
||||
备份命名:
|
||||
|
||||
```text
|
||||
relative_path.replace("/", "__") + "." + yyyyMMdd-HHmmss + ".bak"
|
||||
```
|
||||
|
||||
`CopyFileTool`
|
||||
|
||||
- 只复制单文件。
|
||||
- 源和目标都必须在工作区内。
|
||||
- 不覆盖。
|
||||
- 必须确认。
|
||||
|
||||
`MoveFileTool`
|
||||
|
||||
- 只移动单文件。
|
||||
- 源和目标都必须在工作区内。
|
||||
- 不覆盖。
|
||||
- 必须确认。
|
||||
|
||||
`RenameFileTool`
|
||||
|
||||
- 只允许同目录重命名。
|
||||
- 文件名不能包含路径分隔符。
|
||||
- 不覆盖。
|
||||
- 必须确认。
|
||||
|
||||
`DeleteToTrashTool`
|
||||
|
||||
- 只移动到系统回收站。
|
||||
- 不永久删除。
|
||||
- 单文件删除第一阶段可支持。
|
||||
- 文件夹删除需要强确认。
|
||||
- 批量删除第一阶段拒绝。
|
||||
|
||||
## 9. WorkspaceAgent
|
||||
|
||||
第一版采用半自动 Agent,不做复杂工具循环。
|
||||
|
||||
推荐流程:
|
||||
|
||||
```text
|
||||
用户输入
|
||||
↓
|
||||
WorkspaceController 判断工作区任务
|
||||
↓
|
||||
程序根据关键词和索引检索相关文件
|
||||
↓
|
||||
读取少量相关文件
|
||||
↓
|
||||
把文件树摘要、相关文件内容、用户问题交给模型
|
||||
↓
|
||||
模型返回分析结果或修改建议
|
||||
↓
|
||||
涉及写操作时生成 WorkspaceActionPlan
|
||||
↓
|
||||
用户确认
|
||||
↓
|
||||
执行工具
|
||||
```
|
||||
|
||||
工作区专用系统提示词:
|
||||
|
||||
```text
|
||||
你是 QtDesktopPet 的本地工作区助手。
|
||||
|
||||
你只能基于用户授权的工作区文件进行分析。
|
||||
你不能假设自己看过未读取的文件。
|
||||
你不能要求访问工作区外路径。
|
||||
你不能执行系统命令。
|
||||
你不能生成危险操作指令。
|
||||
涉及创建、修改、移动、重命名、删除文件时,必须先生成清晰的操作计划,等待用户确认。
|
||||
涉及敏感文件、密钥、凭证时,必须提醒风险。
|
||||
回答代码问题时要引用具体文件路径。
|
||||
当信息不足时,说明还需要读取哪些文件。
|
||||
```
|
||||
|
||||
后续可升级为工具循环,但第一版不做。
|
||||
|
||||
## 10. WorkspaceController
|
||||
|
||||
建议接口:
|
||||
|
||||
```cpp
|
||||
class WorkspaceController
|
||||
{
|
||||
public:
|
||||
explicit WorkspaceController();
|
||||
|
||||
bool hasWorkspace() const;
|
||||
QString workspaceRoot() const;
|
||||
|
||||
bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr);
|
||||
void closeWorkspace();
|
||||
|
||||
WorkspaceToolResult scanWorkspace();
|
||||
WorkspaceToolResult handleMessage(const QString &message);
|
||||
|
||||
bool hasPendingPlan() const;
|
||||
WorkspaceActionPlan pendingPlan() const;
|
||||
WorkspaceToolResult confirmPendingPlan();
|
||||
void cancelPendingPlan();
|
||||
|
||||
private:
|
||||
WorkspaceSession m_session;
|
||||
WorkspaceAgent m_agent;
|
||||
std::optional<WorkspaceActionPlan> m_pendingPlan;
|
||||
};
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- `handleMessage()` 不直接执行高风险写操作。
|
||||
- 需要确认时保存到 `m_pendingPlan`。
|
||||
- UI 调用 `confirmPendingPlan()` 后才执行。
|
||||
- 所有错误通过 `WorkspaceToolResult` 返回,不在工具层弹窗。
|
||||
|
||||
## 11. UI 接入
|
||||
|
||||
聊天入口支持:
|
||||
|
||||
```text
|
||||
打开工作区
|
||||
选择工作区
|
||||
当前工作区
|
||||
扫描工作区
|
||||
分析这个项目
|
||||
搜索当前工作区
|
||||
读取 main.cpp
|
||||
打开第一个
|
||||
```
|
||||
|
||||
如果没有选择工作区,提示用户选择目录。
|
||||
|
||||
气泡只显示短状态:
|
||||
|
||||
```text
|
||||
已打开工作区。
|
||||
正在扫描文件。
|
||||
找到了 12 个相关文件。
|
||||
需要你确认修改计划。
|
||||
操作已完成。
|
||||
```
|
||||
|
||||
复杂结果不全部塞进气泡,当前通过独立 Agent 窗口展示。
|
||||
|
||||
当前独立 Agent 窗口可显示:
|
||||
|
||||
- 当前工作区路径。
|
||||
- 文件树。
|
||||
- 搜索结果。
|
||||
- 已读取文件。
|
||||
- AI 分析结果。
|
||||
- 待确认操作计划。
|
||||
- 执行结果。
|
||||
- 最近操作历史。
|
||||
|
||||
## 12. 意图分发
|
||||
|
||||
后续建议新增:
|
||||
|
||||
```cpp
|
||||
enum class UserIntentType
|
||||
{
|
||||
Chat,
|
||||
Reminder,
|
||||
Weather,
|
||||
Workspace,
|
||||
FileOperation,
|
||||
LaunchApp
|
||||
};
|
||||
```
|
||||
|
||||
意图原则:
|
||||
|
||||
- `Reminder` 保持最高优先级。
|
||||
- 明确天气问答走 `Weather`。
|
||||
- 明确工作区、代码、项目分析走 `Workspace`。
|
||||
- 当前阶段,未明确“工作区/项目/代码”且没有工作区文件名特征的普通文件读写请求仍可走旧 `FileOperation`。
|
||||
- 明确应用名启动走 `LaunchApp`。
|
||||
- 其余走 `Chat` 或输入框联网模式。
|
||||
|
||||
不能用宽关键词简单吞掉所有请求:
|
||||
|
||||
- “打开微信”应走 `LaunchApp`。
|
||||
- “打开文件夹”这种泛请求不走 `Workspace`;“打开 docs / 打开这个项目里的 docs”走 `Workspace`。
|
||||
- “搜索 Qt 最新版本”不应走工作区搜索。
|
||||
- “搜索这个项目里的 main”应走工作区搜索。
|
||||
|
||||
## 13. 迁移步骤
|
||||
|
||||
1. 新增 `src/workspace/`,不改旧 `src/fileops/`。
|
||||
2. CMake 登记 workspace 新文件。
|
||||
3. `PetWindow` 增加 `WorkspaceController` 成员。
|
||||
4. 新增工作区打开、扫描、搜索、读取入口。
|
||||
5. v1b 支持打开和定位;写操作计划和确认框从后续阶段开始。
|
||||
6. 新模块覆盖旧文件操作能力后,`handleFileOperationChatMessage()` 转发到 `WorkspaceController`。
|
||||
7. 确认无旧引用后删除 `src/fileops/`。
|
||||
8. 更新 CMake、README、测试文档。
|
||||
|
||||
删除旧文件前必须执行:
|
||||
|
||||
```text
|
||||
rg "FileOperationManager|FileSandbox|FileBackupManager|src/fileops|FileOperation"
|
||||
```
|
||||
|
||||
确认没有有效代码引用后再删。
|
||||
|
||||
## 14. 日志要求
|
||||
|
||||
允许记录:
|
||||
|
||||
- 打开工作区路径。
|
||||
- 扫描文件数量。
|
||||
- 执行的工具名称。
|
||||
- 操作是否成功。
|
||||
- 错误摘要。
|
||||
|
||||
禁止记录:
|
||||
|
||||
- API Key。
|
||||
- Authorization Header。
|
||||
- 完整敏感文件内容。
|
||||
- `.env` 内容。
|
||||
- 密钥文件内容。
|
||||
- 用户完整隐私文本。
|
||||
|
||||
使用项目已有 `Logger`,不要新增零散调试输出。
|
||||
|
||||
## 15. 测试清单
|
||||
|
||||
### 工作区选择
|
||||
|
||||
- 选择普通项目目录。
|
||||
- 选择空目录。
|
||||
- 选择不存在目录。
|
||||
- 选择 `C:/Windows`,应拒绝。
|
||||
- 选择 `Program Files`,应拒绝。
|
||||
- 选择中文路径。
|
||||
- 选择包含空格的路径。
|
||||
|
||||
### 扫描
|
||||
|
||||
- 扫描普通 Qt 项目。
|
||||
- 扫描包含 build 目录的项目。
|
||||
- 扫描包含 `.git` 的项目。
|
||||
- 扫描大量文件目录。
|
||||
- 扫描中文文件名。
|
||||
- 扫描深层目录。
|
||||
|
||||
要求:
|
||||
|
||||
- 不崩溃。
|
||||
- 不扫系统目录。
|
||||
- 不进入跳过目录。
|
||||
- 结果数量受限制。
|
||||
|
||||
### 搜索文件
|
||||
|
||||
- 搜索 `main`。
|
||||
- 搜索 `.cpp`。
|
||||
- 搜索 `README`。
|
||||
- 搜索不存在文件。
|
||||
- 搜索中文文件名。
|
||||
|
||||
要求:
|
||||
|
||||
- 最多返回 30 条。
|
||||
- 显示相对路径。
|
||||
- 结果可用于后续“打开第几个”。
|
||||
|
||||
### 搜索文本
|
||||
|
||||
- 搜索 `class`。
|
||||
- 搜索 `include`。
|
||||
- 搜索中文关键词。
|
||||
- 搜索不存在关键词。
|
||||
- 搜索超大文件。
|
||||
|
||||
要求:
|
||||
|
||||
- 显示路径、行号、片段。
|
||||
- 跳过二进制文件。
|
||||
- 跳过超大文件。
|
||||
- 不读取敏感文件。
|
||||
|
||||
### 读取文件
|
||||
|
||||
- 读取 `.cpp`。
|
||||
- 读取 `.md`。
|
||||
- 读取 `.json`。
|
||||
- 读取图片应拒绝。
|
||||
- 读取 `.exe` 应拒绝。
|
||||
- 读取 `.env` 应拒绝或强确认。
|
||||
- 读取工作区外文件应拒绝。
|
||||
|
||||
### 创建文件
|
||||
|
||||
- 在工作区创建 `test.txt`。
|
||||
- 创建已存在文件应拒绝。
|
||||
- 创建非法文件名应拒绝。
|
||||
- 创建 `.bat` 应拒绝。
|
||||
- 创建到工作区外应拒绝。
|
||||
|
||||
### 修改文件
|
||||
|
||||
- 修改普通 `.txt`。
|
||||
- 修改 `.cpp`。
|
||||
- 修改前生成备份。
|
||||
- 修改二进制文件应拒绝。
|
||||
- 修改敏感文件应拒绝。
|
||||
|
||||
要求:
|
||||
|
||||
- 必须确认。
|
||||
- 必须备份。
|
||||
- 失败不能破坏原文件。
|
||||
|
||||
### 移动和重命名
|
||||
|
||||
- 移动普通文件。
|
||||
- 移动到已存在目标应拒绝。
|
||||
- 重命名普通文件。
|
||||
- 重命名为非法文件名应拒绝。
|
||||
- 移动到工作区外应拒绝。
|
||||
|
||||
### 删除到回收站
|
||||
|
||||
- 删除普通文件到回收站。
|
||||
- 删除文件夹到回收站。
|
||||
- 删除工作区外文件应拒绝。
|
||||
- 永久删除请求应拒绝。
|
||||
- 批量删除请求第一阶段应拒绝。
|
||||
|
||||
### 对话上下文
|
||||
|
||||
```text
|
||||
用户:搜索 README
|
||||
用户:打开第一个
|
||||
|
||||
用户:搜索 fileops
|
||||
用户:读取第二个
|
||||
|
||||
用户:找一下包含 FileOperationManager 的文件
|
||||
用户:总结这些文件的作用
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 系统能记住上一轮搜索结果。
|
||||
- “第一个”“第二个”能正确解析。
|
||||
- 上下文过期后要求用户重新选择。
|
||||
|
||||
## 16. 静态推演要求
|
||||
|
||||
每完成一个阶段,需要记录:
|
||||
|
||||
- 入口路径。
|
||||
- 业务模块调用链。
|
||||
- 路径校验点。
|
||||
- 用户确认点。
|
||||
- 失败回滚策略。
|
||||
- 不支持能力的拒绝话术。
|
||||
- 对提醒、天气、联网模式、应用启动的回归影响。
|
||||
|
||||
## 17. 最终验收标准
|
||||
|
||||
本轮重构完成后至少达到:
|
||||
|
||||
1. 用户可以选择一个本地项目目录作为工作区。
|
||||
2. 程序可以扫描工作区并建立文件索引。
|
||||
3. 用户可以通过对话搜索文件。
|
||||
4. 用户可以通过对话读取和分析文本文件。
|
||||
5. 用户可以要求分析项目结构。
|
||||
6. 用户可以创建文本文件和文件夹。
|
||||
7. 用户可以移动、重命名、删除到回收站。
|
||||
8. 所有写操作必须展示计划并等待确认。
|
||||
9. 所有路径必须限制在工作区内。
|
||||
10. 不允许永久删除。
|
||||
11. 不允许执行脚本或命令。
|
||||
12. 不允许默认读取敏感文件。
|
||||
13. 旧文件操作功能被新 Workspace 系统替代。
|
||||
14. 项目可以由用户手动构建通过。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,13 @@
|
||||
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m
|
||||
```
|
||||
|
||||
指定 PID 采样:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 12345 -DurationSeconds 600
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Pid 12345 -Scenario idle-10m -DurationSeconds 600
|
||||
```
|
||||
|
||||
输出目录默认是:
|
||||
@@ -37,27 +37,34 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 1
|
||||
reports/perf/
|
||||
```
|
||||
|
||||
`reports/perf/` 已加入 `.gitignore`。需要沉淀结论时,只把摘要写入本文档,不提交原始采样 CSV。
|
||||
`reports/perf/` 已加入 `.gitignore`。原始 CSV 不提交;使用报告脚本生成统计摘要,审核并脱敏后写入对应的 `docs/test-records/` 记录:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/New-PerformanceReport.ps1 `
|
||||
-InputPath reports/perf/<sample>.csv -Scenario idle-5m
|
||||
```
|
||||
|
||||
默认只强制判定进程意外退出和无响应。CPU、内存、句柄和线程阈值保存在 `internal/qa/config/performance-thresholds.json`,取得稳定 Release 基线后再按场景填写。
|
||||
|
||||
下表中的 `perf_sample.ps1` 均指 `internal/qa/scripts/perf_sample.ps1`。
|
||||
|
||||
## 检查项
|
||||
|
||||
| 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 启动后静置 | 启动程序后不操作 5 分钟 | `tools/perf_sample.ps1` 默认参数 | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
|
||||
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `tools/perf_sample.ps1 -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
|
||||
| 启动后静置 | 启动程序后不操作 5 分钟 | `perf_sample.ps1 -Scenario idle-5m` | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
|
||||
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `perf_sample.ps1 -Scenario idle-10m -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
|
||||
| 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO |
|
||||
| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `tools/perf_sample.ps1 -DurationSeconds 300` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO |
|
||||
| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `perf_sample.ps1 -Scenario show-hide` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO |
|
||||
| 重复启动 / 单实例 | 已运行时连续双击 exe 多次;隐藏到托盘后再双击 exe | 任务管理器或 `Get-Process QtDesktopPet` | 始终只有一个进程;隐藏状态可被重复启动唤醒 | TODO | TODO | 检查 QLocalServer 激活路径 |
|
||||
| 设置页居中 / 置前 | 从托盘打开设置页;多屏环境重复打开;设置页已打开时再次启动 exe | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 |
|
||||
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `tools/perf_sample.ps1 -DurationSeconds 300` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
|
||||
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
||||
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
|
||||
| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
|
||||
| 提醒触发 | 创建 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 | 使用临时测试目录,不操作真实重要文件 |
|
||||
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `perf_sample.ps1 -Scenario state-switch` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
|
||||
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `perf_sample.ps1 -Scenario prewarm-unload -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
||||
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `perf_sample.ps1 -Scenario scale-topmost` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
|
||||
| AI 对话 | 连续发送 20 轮短消息 | `perf_sample.ps1 -Scenario ai-20-turns -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
|
||||
| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `perf_sample.ps1 -Scenario reminder-trigger` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 |
|
||||
| 天气查询 | 连续查询当前/明天/未来三天天气 | `perf_sample.ps1 -Scenario weather-queries` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 |
|
||||
| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `perf_sample.ps1 -Scenario web-queries` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 |
|
||||
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
|
||||
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
|
||||
|
||||
@@ -80,7 +87,7 @@ reports/perf/
|
||||
```text
|
||||
1. 后台线程预热
|
||||
2. 单帧级缓存
|
||||
3. 自动化长期压测记录
|
||||
3. 经 Release 基线校准的自动性能阈值
|
||||
```
|
||||
|
||||
## 发布包说明
|
||||
@@ -90,6 +97,7 @@ reports/perf/
|
||||
```text
|
||||
tools/
|
||||
docs/
|
||||
internal/
|
||||
reports/
|
||||
build/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# QtDesktopPet 预发布测试记录
|
||||
|
||||
## 构建信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 测试日期 | 2026-07-17 |
|
||||
| 版本 / 发布阶段 | 当前开发版 / 预发布回归进行中 |
|
||||
| 测试源码基线 | `d0d1de4-dirty`;包含已确认的产品能力收缩、无用代码清理和内部 QA 基础设施 |
|
||||
| 工作树说明 | 用户手测后只新增测试记录,未再修改产品业务代码 |
|
||||
| 构建类型 | 用户未反馈,待补充 |
|
||||
| 编译器 / Qt | 待补充 |
|
||||
| 操作系统 | Windows,具体版本待补充 |
|
||||
| 显示器 / DPI | 待补充 |
|
||||
| 测试人 | 用户手动测试 |
|
||||
|
||||
## 自动化摘要
|
||||
|
||||
| 检查 | 结果 | 摘要 |
|
||||
| --- | --- | --- |
|
||||
| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;尚未手动构建内部 QA 工程 |
|
||||
| 静态检查 | PASS | 13 项通过,0 项失败 |
|
||||
| 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 |
|
||||
| GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA |
|
||||
|
||||
## 已执行手测
|
||||
|
||||
| 用例 | 结果 | 实际结果 / 备注 |
|
||||
| --- | --- | --- |
|
||||
| 启动 | PASS | 用户确认通过 |
|
||||
| 拖动 | PASS | 用户确认通过 |
|
||||
| 置顶及重启保持 | PASS | 用户确认通过 |
|
||||
| 托盘隐藏 / 显示 | PASS | 用户确认通过 |
|
||||
| 单实例唤醒 | PASS | 用户确认通过 |
|
||||
| 设置页居中 / 置前 | PASS | 用户确认通过 |
|
||||
| 应用设置保存及重启保持 | PASS | 用户确认通过 |
|
||||
|
||||
## 批次状态
|
||||
|
||||
| 批次 | 范围 | 结果 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 |
|
||||
| T02 | 应用设置与开机自启动 | IN_PROGRESS | 应用设置持久化通过;开机自启动尚未测试 |
|
||||
| T03 | 角色管理 | NOT_RUN | |
|
||||
| T04 | AI 与聊天历史 | NOT_RUN | |
|
||||
| T05 | 定时提醒 | NOT_RUN | |
|
||||
| T06 | 天气查询 | NOT_RUN | |
|
||||
| T07 | 联网模式 | NOT_RUN | |
|
||||
| T08 | 已移除能力与安全边界 | NOT_RUN | |
|
||||
| T09 | 配置损坏与隐私 | NOT_RUN | |
|
||||
| T10 | 打包、安装与卸载 | NOT_RUN | |
|
||||
|
||||
## 性能状态
|
||||
|
||||
实际 QtDesktopPet 性能场景尚未执行。QA 脚本自检结果不得作为产品性能结论。
|
||||
|
||||
## 缺陷与复测
|
||||
|
||||
当前已执行项目未发现缺陷。
|
||||
|
||||
## 当前结论
|
||||
|
||||
- 已执行手测:7 项 `PASS`。
|
||||
- 已执行静态检查:13 项 `PASS`。
|
||||
- 未执行项目仍较多,本记录状态为 `IN_PROGRESS`。
|
||||
- 当前不能据此判定可发布。
|
||||
@@ -0,0 +1,48 @@
|
||||
# QtDesktopPet QA 基础设施验证记录
|
||||
|
||||
## 构建信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 测试日期 | 2026-07-17 |
|
||||
| 验证范围 | 内部 QA 基础设施,不是产品发布验收 |
|
||||
| Git commit | `d0d1de4` |
|
||||
| 工作树状态 | dirty;包含产品能力收缩、无用代码清理和本次 QA 基础设施改动 |
|
||||
| 构建类型 | 未构建 |
|
||||
| 操作系统 | Windows / Asia-Shanghai 环境 |
|
||||
|
||||
## 执行结果
|
||||
|
||||
| 检查 | 结果 | 摘要 |
|
||||
| --- | --- | --- |
|
||||
| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;本轮未调用 CMake、编译器或构建命令 |
|
||||
| 静态检查 | PASS | 13 项通过,0 项失败 |
|
||||
| PowerShell / JSON 解析 | PASS | 内部 QA 脚本和配置均可解析 |
|
||||
| 性能报告器 fixture | PASS | 脱敏稳定样本成功生成 Markdown 报告,未出现退出或无响应判定 |
|
||||
| 性能采样与报告链路 | PASS | 对临时 PowerShell 进程执行 1 秒采样并成功生成 PASS 报告;不代表产品性能 |
|
||||
| 发布隔离规则 | PASS | 静态确认 GitHub 导出排除 `docs/internal/reports`,发布 CMake 和打包脚本不依赖内部 QA |
|
||||
|
||||
## 静态检查明细
|
||||
|
||||
- 92 个生产 `.h/.cpp` 均登记在根 CMake。
|
||||
- 根 CMake 不引用缺失生产源码。
|
||||
- 6 个内部测试翻译单元均登记在内部 QA CMake。
|
||||
- 活跃生产代码中没有已移除模块路径、类型或意图动作。
|
||||
- 生产代码没有 `Q_OBJECT`,保持 `CMAKE_AUTOMOC OFF`。
|
||||
- 9 个发布必需输入存在。
|
||||
- 默认角色清单可解析,所有声明状态均有 PNG 帧。
|
||||
- GitHub 导出规则包含 `docs/internal/reports` 排除项。
|
||||
- 生产构建和打包不依赖 `internal/qa`。
|
||||
- `git diff --check` 通过,仅显示 Git 的 LF/CRLF 提示。
|
||||
|
||||
## 限制与后续
|
||||
|
||||
- 没有验证 C++ 测试能否编译和运行;需要用户使用匹配 Qt Kit 手动构建内部 QA 工程。
|
||||
- 没有运行桌宠 Release,因此没有功能、UI、网络、安装或真实性能结论。
|
||||
- 性能 fixture 只验证报告脚本算法和输出路径,不代表 QtDesktopPet 实际性能。
|
||||
- 下一份记录应基于重新构建后的 Release,并从 `TEMPLATE.md` 创建正式验收记录。
|
||||
|
||||
## 最终结论
|
||||
|
||||
- QA 基础设施:`PASS WITH LIMITATIONS`
|
||||
- 产品发布状态:`NOT EVALUATED`
|
||||
@@ -0,0 +1,27 @@
|
||||
# 测试记录维护规则
|
||||
|
||||
本目录保存 QtDesktopPet 每次正式回归、候选发布和性能验收的长期记录。验收标准定义在
|
||||
`../QtDesktopPet_测试清单与验收标准.md`,本目录只记录某一次构建的实际执行结果。
|
||||
|
||||
## 文件规则
|
||||
|
||||
- 每次验收创建独立文件:`YYYY-MM-DD_<version>_<commit>.md`。
|
||||
- 已完成记录不覆盖;复测结果追加在原记录的“缺陷与复测”部分。
|
||||
- `index.md` 维护所有测试轮次和最终结论。
|
||||
- 工作树不干净时,commit 后增加 `-dirty`,并记录差异摘要。
|
||||
- 状态统一使用 `PASS / FAIL / BLOCKED / NOT_RUN`。
|
||||
|
||||
## 数据边界
|
||||
|
||||
- 允许提交:测试环境、用例结论、脱敏错误摘要、性能统计摘要和缺陷编号。
|
||||
- 禁止提交:API Key、Authorization、完整用户对话、完整服务端错误正文、真实用户路径和原始性能 CSV。
|
||||
- 原始性能 CSV 保存在 `reports/perf/`,该目录不进入 Git。
|
||||
- 自动化临时输出保存在 `internal/qa/out/`,该目录不进入 Git。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 从 `TEMPLATE.md` 创建本次记录并填写不可变构建信息。
|
||||
2. 执行内部自动化和静态检查,粘贴摘要而不是完整原始日志。
|
||||
3. 按功能测试清单分批手测,用户反馈后更新本次记录。
|
||||
4. 执行性能采样,将审核后的 Markdown 摘要纳入本次记录。
|
||||
5. 在 `index.md` 登记结论;存在阻断问题时不得标记为可发布。
|
||||
@@ -0,0 +1,64 @@
|
||||
# QtDesktopPet 测试记录
|
||||
|
||||
## 构建信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 测试日期 | TODO |
|
||||
| 版本 / 发布阶段 | TODO |
|
||||
| Git commit | TODO |
|
||||
| 工作树状态 | clean / dirty;差异摘要:TODO |
|
||||
| 构建类型 | Debug / Release |
|
||||
| 编译器 / Qt | TODO |
|
||||
| 操作系统 | TODO |
|
||||
| CPU / 内存 | TODO |
|
||||
| 显示器 / DPI | TODO |
|
||||
| 测试人 | TODO |
|
||||
|
||||
## 自动化摘要
|
||||
|
||||
| 检查 | 结果 | 摘要 |
|
||||
| --- | --- | --- |
|
||||
| 核心逻辑测试 | NOT_RUN | TODO |
|
||||
| 静态检查 | NOT_RUN | TODO |
|
||||
| GitHub 导出隔离 | NOT_RUN | TODO |
|
||||
|
||||
## 手测结果
|
||||
|
||||
| 批次 | 范围 | 结果 | 失败 / 阻塞用例 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T01 | 基础窗口与托盘 | NOT_RUN | | |
|
||||
| T02 | 应用设置与开机自启动 | NOT_RUN | | |
|
||||
| T03 | 角色管理 | NOT_RUN | | |
|
||||
| T04 | AI 与聊天历史 | NOT_RUN | | |
|
||||
| T05 | 定时提醒 | NOT_RUN | | |
|
||||
| T06 | 天气查询 | NOT_RUN | | |
|
||||
| T07 | 联网模式 | NOT_RUN | | |
|
||||
| T08 | 已移除能力与安全边界 | NOT_RUN | | |
|
||||
| T09 | 配置损坏与隐私 | NOT_RUN | | |
|
||||
| T10 | 打包、安装与卸载 | NOT_RUN | | |
|
||||
|
||||
## 性能摘要
|
||||
|
||||
| 场景 | CSV 时间 | 报告结论 | 关键指标 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| idle-5m | NOT_RUN | NOT_RUN | | |
|
||||
| idle-10m | NOT_RUN | NOT_RUN | | |
|
||||
| hidden-5m | NOT_RUN | NOT_RUN | | |
|
||||
| ai-20-turns | NOT_RUN | NOT_RUN | | |
|
||||
| reminder-trigger | NOT_RUN | NOT_RUN | | |
|
||||
| weather-queries | NOT_RUN | NOT_RUN | | |
|
||||
| web-queries | NOT_RUN | NOT_RUN | | |
|
||||
|
||||
## 缺陷与复测
|
||||
|
||||
| 缺陷 ID | 首次现象 | 严重级别 | 修复 commit | 复测结果 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| | | | | | |
|
||||
|
||||
## 最终结论
|
||||
|
||||
- 结论:`BLOCKED / CONDITIONAL PASS / PASS`
|
||||
- 未执行项:TODO
|
||||
- 已知风险:TODO
|
||||
- 是否允许发布:否 / 是
|
||||
@@ -0,0 +1,8 @@
|
||||
# QtDesktopPet 测试记录索引
|
||||
|
||||
| 日期 | 版本 / 阶段 | Commit | 构建 | 自动化 | 手测 | 性能 | 发布结论 | 记录 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 2026-07-17 | 预发布回归进行中 | `d0d1de4-dirty` | 用户构建,详情待补 | 静态 13/13;C++ NOT_RUN | 7 PASS,其余 NOT_RUN | NOT_RUN | IN_PROGRESS | `2026-07-17_pre-release_d0d1de4-dirty.md` |
|
||||
| 2026-07-17 | QA 基础设施 | `d0d1de4-dirty` | 未构建 | 静态 13/13;C++ NOT_RUN | NOT_RUN | fixture PASS | NOT EVALUATED | `2026-07-17_qa-infrastructure_d0d1de4-dirty.md` |
|
||||
|
||||
正式记录按时间倒序添加。模板和维护规则分别见 `TEMPLATE.md` 与 `README.md`。
|
||||
@@ -0,0 +1,64 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(QtDesktopPetInternalQA LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_AUTOMOC OFF)
|
||||
|
||||
get_filename_component(DEFAULT_PET_SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
|
||||
set(PET_SOURCE_ROOT "${DEFAULT_PET_SOURCE_ROOT}" CACHE PATH "QtDesktopPet source root")
|
||||
|
||||
if (NOT EXISTS "${PET_SOURCE_ROOT}/src" OR NOT EXISTS "${PET_SOURCE_ROOT}/CMakeLists.txt")
|
||||
message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}")
|
||||
endif()
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core)
|
||||
|
||||
add_executable(QtDesktopPetCoreTests
|
||||
include/TestHarness.h
|
||||
tests/TestMain.cpp
|
||||
tests/assistant/IntentRoutingTests.cpp
|
||||
tests/reminder/ReminderTests.cpp
|
||||
tests/weather/WeatherTests.cpp
|
||||
tests/web/WebCapabilityTests.cpp
|
||||
tests/ai/ConversationStoreTests.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h
|
||||
${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h
|
||||
${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.cpp
|
||||
${PET_SOURCE_ROOT}/src/assistant/UserIntent.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderParser.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderParser.cpp
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.h
|
||||
${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherParser.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherParser.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h
|
||||
${PET_SOURCE_ROOT}/src/weather/WeatherConfig.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebCapabilityDetector.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebCapabilityDetector.cpp
|
||||
${PET_SOURCE_ROOT}/src/web/WebChatTypes.h
|
||||
${PET_SOURCE_ROOT}/src/web/WebConfig.h
|
||||
${PET_SOURCE_ROOT}/src/config/AIConfig.h
|
||||
${PET_SOURCE_ROOT}/src/config/AIConfig.cpp
|
||||
${PET_SOURCE_ROOT}/src/ai/ConversationStore.h
|
||||
${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp
|
||||
${PET_SOURCE_ROOT}/src/ai/LLMTypes.h
|
||||
${PET_SOURCE_ROOT}/src/util/Logger.h
|
||||
${PET_SOURCE_ROOT}/src/util/Logger.cpp
|
||||
)
|
||||
|
||||
target_include_directories(QtDesktopPetCoreTests
|
||||
PRIVATE
|
||||
"${CMAKE_CURRENT_LIST_DIR}/include"
|
||||
"${PET_SOURCE_ROOT}"
|
||||
)
|
||||
|
||||
target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core)
|
||||
|
||||
include(CTest)
|
||||
add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests)
|
||||
@@ -0,0 +1,61 @@
|
||||
# QtDesktopPet Internal QA
|
||||
|
||||
This directory contains development-only quality assurance assets. It is tracked
|
||||
by the development repository and excluded from the public GitHub export and
|
||||
release packages.
|
||||
|
||||
## Structure
|
||||
|
||||
- `tests/`: deterministic C++ tests grouped by production module.
|
||||
- `include/`: the lightweight test harness. It does not require `Q_OBJECT`.
|
||||
- `fixtures/`: sanitized, versioned test data.
|
||||
- `config/`: extensible static-check and performance threshold configuration.
|
||||
- `scripts/`: static checks, performance sampling, and report generation.
|
||||
- `out/`: local generated output. This directory is ignored by Git.
|
||||
|
||||
## Core Logic Tests
|
||||
|
||||
The QA project is intentionally independent from the production CMake project.
|
||||
Configure it from this directory when a matching Qt toolchain is available:
|
||||
|
||||
```powershell
|
||||
cmake -S internal/qa -B build/internal-qa `
|
||||
-DCMAKE_PREFIX_PATH=D:/Qt/6.5.3/mingw_64
|
||||
cmake --build build/internal-qa
|
||||
ctest --test-dir build/internal-qa --output-on-failure
|
||||
```
|
||||
|
||||
The test process enables Qt test mode and uses temporary directories for file
|
||||
fixtures. It must not read or modify the normal QtDesktopPet user configuration.
|
||||
|
||||
## Static Checks
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File internal/qa/scripts/Invoke-StaticChecks.ps1
|
||||
```
|
||||
|
||||
The script returns a non-zero exit code when a required invariant fails. Extend
|
||||
`config/static-checks.json` when modules or public-export boundaries change.
|
||||
|
||||
## Performance
|
||||
|
||||
Start QtDesktopPet, then sample a named scenario:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File internal/qa/scripts/perf_sample.ps1 `
|
||||
-Scenario idle-5m -DurationSeconds 300
|
||||
```
|
||||
|
||||
Generate a Markdown summary from the resulting CSV:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File internal/qa/scripts/New-PerformanceReport.ps1 `
|
||||
-InputPath reports/perf/<sample>.csv `
|
||||
-Scenario idle-5m
|
||||
```
|
||||
|
||||
Raw CSV files stay under `reports/perf/` and are not committed. Reviewed,
|
||||
sanitized summaries belong in `docs/test-records/`.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"defaults": {
|
||||
"allowUnexpectedExit": false,
|
||||
"maxUnresponsiveSamples": 0,
|
||||
"maxAverageCpuPercent": null,
|
||||
"maxP95CpuPercent": null,
|
||||
"maxPrivateMemoryGrowthMB": null,
|
||||
"maxWorkingSetGrowthMB": null,
|
||||
"maxHandleGrowth": null,
|
||||
"maxThreadGrowth": null
|
||||
},
|
||||
"scenarios": {
|
||||
"idle-5m": {},
|
||||
"idle-10m": {},
|
||||
"hidden-5m": {},
|
||||
"ai-20-turns": {},
|
||||
"reminder-trigger": {},
|
||||
"weather-queries": {},
|
||||
"web-queries": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"productionSourceRoots": [
|
||||
"src"
|
||||
],
|
||||
"productionExtensions": [
|
||||
".h",
|
||||
".cpp"
|
||||
],
|
||||
"productionCmakeFile": "CMakeLists.txt",
|
||||
"qaCmakeFile": "internal/qa/CMakeLists.txt",
|
||||
"qaTestRoots": [
|
||||
"internal/qa/tests"
|
||||
],
|
||||
"activeScanPaths": [
|
||||
"CMakeLists.txt",
|
||||
"main.cpp",
|
||||
"src"
|
||||
],
|
||||
"forbiddenReferences": [
|
||||
{
|
||||
"name": "removed module paths",
|
||||
"pattern": "src[\\\\/](?:fileops|launcher|workspace|search)[\\\\/]"
|
||||
},
|
||||
{
|
||||
"name": "removed module types",
|
||||
"pattern": "\\b(?:FileOperationManager|FileSandbox|AppLaunchManager|AppDiscovery|WorkspaceController|WorkspaceAgent|WorkspaceAgentWindow|WorkspacePanel|GlobalHotkeyManager|WebSearchManager|SearchAnswerSynthesizer)\\b"
|
||||
},
|
||||
{
|
||||
"name": "removed intent actions",
|
||||
"pattern": "\\b(?:UserIntentType|CommandDispatchAction)::(?:FileOperation|LaunchApp|Workspace|Search|UnsupportedTool)\\b"
|
||||
}
|
||||
],
|
||||
"forbidQObjectMacro": true,
|
||||
"requiredPaths": [
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"installer/QtDesktopPet.iss",
|
||||
"resources/characters/shiroko/character.json",
|
||||
"resources/characters/shiroko/preview.png",
|
||||
"resources/icons/app_icon.ico",
|
||||
"resources/sounds/reminders/reminder_default.wav",
|
||||
"tools/package_release.ps1",
|
||||
"tools/prepare_github_export.ps1"
|
||||
],
|
||||
"publicExportExcludedRoots": [
|
||||
"docs",
|
||||
"internal",
|
||||
"reports"
|
||||
],
|
||||
"releaseIsolationFiles": [
|
||||
"CMakeLists.txt",
|
||||
"tools/package_release.ps1"
|
||||
],
|
||||
"releaseForbiddenPatterns": [
|
||||
"internal/qa",
|
||||
"internal\\\\qa"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"Scenario","SampleIndex","TimestampUtc","Pid","CpuPercent","WorkingSetMB","PrivateMemoryMB","HandleCount","ThreadCount","Responding","Path","Status"
|
||||
"fixture-stable","0","2026-07-17T00:00:00.0000000Z","1000","0","100.0","80.0","200","12","True","C:\\Fixture\\QtDesktopPet.exe","running"
|
||||
"fixture-stable","1","2026-07-17T00:00:05.0000000Z","1000","1.5","101.0","80.5","201","12","True","C:\\Fixture\\QtDesktopPet.exe","running"
|
||||
"fixture-stable","2","2026-07-17T00:00:10.0000000Z","1000","2.5","102.0","81.0","202","13","True","C:\\Fixture\\QtDesktopPet.exe","running"
|
||||
|
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace Qa
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
std::string name;
|
||||
std::function<void()> function;
|
||||
};
|
||||
|
||||
inline std::vector<TestCase> ®istry()
|
||||
{
|
||||
static std::vector<TestCase> tests;
|
||||
return tests;
|
||||
}
|
||||
|
||||
class Registrar
|
||||
{
|
||||
public:
|
||||
Registrar(const char *name, std::function<void()> function)
|
||||
{
|
||||
registry().push_back({name, std::move(function)});
|
||||
}
|
||||
};
|
||||
|
||||
[[noreturn]] inline void fail(const char *expression, const char *file, int line)
|
||||
{
|
||||
std::ostringstream stream;
|
||||
stream << file << ':' << line << ": expectation failed: " << expression;
|
||||
throw std::runtime_error(stream.str());
|
||||
}
|
||||
}
|
||||
|
||||
#define QA_JOIN_IMPL(left, right) left##right
|
||||
#define QA_JOIN(left, right) QA_JOIN_IMPL(left, right)
|
||||
|
||||
#define QA_TEST(suiteName, testName) \
|
||||
static void QA_JOIN(qa_test_function_, __LINE__)(); \
|
||||
static Qa::Registrar QA_JOIN(qa_test_registrar_, __LINE__)( \
|
||||
#suiteName "." #testName, QA_JOIN(qa_test_function_, __LINE__)); \
|
||||
static void QA_JOIN(qa_test_function_, __LINE__)()
|
||||
|
||||
#define QA_EXPECT(expression) \
|
||||
do \
|
||||
{ \
|
||||
if (!(expression)) \
|
||||
{ \
|
||||
Qa::fail(#expression, __FILE__, __LINE__); \
|
||||
} \
|
||||
} while (false)
|
||||
|
||||
#define QA_EXPECT_EQ(actual, expected) \
|
||||
do \
|
||||
{ \
|
||||
const auto qaActualValue = (actual); \
|
||||
const auto qaExpectedValue = (expected); \
|
||||
if (!(qaActualValue == qaExpectedValue)) \
|
||||
{ \
|
||||
Qa::fail(#actual " == " #expected, __FILE__, __LINE__); \
|
||||
} \
|
||||
} while (false)
|
||||
@@ -0,0 +1,321 @@
|
||||
param(
|
||||
[string]$RepoRoot = "",
|
||||
[string]$ConfigPath = "",
|
||||
[string]$ReportPath = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-AbsolutePath {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$BasePath
|
||||
)
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
|
||||
}
|
||||
|
||||
function Get-RelativePath {
|
||||
param(
|
||||
[string]$Root,
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
||||
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Path is outside repository root: $fullPath"
|
||||
}
|
||||
return $fullPath.Substring($rootPrefix.Length).Replace('\', '/')
|
||||
}
|
||||
|
||||
function Get-TextFiles {
|
||||
param(
|
||||
[string]$Root,
|
||||
[object[]]$Paths
|
||||
)
|
||||
|
||||
$allowedExtensions = @(".h", ".hpp", ".cpp", ".cxx", ".md", ".txt", ".json", ".cmake", ".ps1")
|
||||
$files = New-Object System.Collections.Generic.List[System.IO.FileInfo]
|
||||
foreach ($relativePath in $Paths) {
|
||||
$absolutePath = Resolve-AbsolutePath -Path ([string]$relativePath) -BasePath $Root
|
||||
if (-not (Test-Path -LiteralPath $absolutePath)) {
|
||||
continue
|
||||
}
|
||||
$item = Get-Item -LiteralPath $absolutePath
|
||||
if (-not $item.PSIsContainer) {
|
||||
$files.Add($item)
|
||||
continue
|
||||
}
|
||||
foreach ($file in Get-ChildItem -LiteralPath $absolutePath -Recurse -File) {
|
||||
if ($allowedExtensions -contains $file.Extension.ToLowerInvariant()) {
|
||||
$files.Add($file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return @($files | Sort-Object FullName -Unique)
|
||||
}
|
||||
|
||||
function Invoke-GitDiffCheck {
|
||||
param([string]$Root)
|
||||
|
||||
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$startInfo.FileName = "git"
|
||||
$escapedRoot = $Root.Replace('"', '\"')
|
||||
$startInfo.Arguments = "-C `"$escapedRoot`" diff --check"
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.CreateNoWindow = $true
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
$process.StartInfo = $startInfo
|
||||
if (-not $process.Start()) {
|
||||
throw "Unable to start git for diff validation."
|
||||
}
|
||||
$standardOutput = $process.StandardOutput.ReadToEnd()
|
||||
$standardError = $process.StandardError.ReadToEnd()
|
||||
$process.WaitForExit()
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
Output = ($standardOutput + $standardError).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
$defaultRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||
$resolvedRoot = if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
|
||||
$defaultRoot
|
||||
} else {
|
||||
Resolve-AbsolutePath -Path $RepoRoot -BasePath (Get-Location)
|
||||
}
|
||||
$resolvedConfig = if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
|
||||
Join-Path $resolvedRoot "internal/qa/config/static-checks.json"
|
||||
} else {
|
||||
Resolve-AbsolutePath -Path $ConfigPath -BasePath $resolvedRoot
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $resolvedConfig -PathType Leaf)) {
|
||||
throw "Static-check configuration was not found: $resolvedConfig"
|
||||
}
|
||||
|
||||
$config = Get-Content -LiteralPath $resolvedConfig -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($config.schemaVersion -ne 1) {
|
||||
throw "Unsupported static-check configuration version: $($config.schemaVersion)"
|
||||
}
|
||||
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
function Add-Result {
|
||||
param(
|
||||
[string]$Name,
|
||||
[bool]$Passed,
|
||||
[string]$Details
|
||||
)
|
||||
$results.Add([pscustomobject]@{
|
||||
Name = $Name
|
||||
Passed = $Passed
|
||||
Details = $Details
|
||||
})
|
||||
}
|
||||
|
||||
$cmakePath = Resolve-AbsolutePath -Path ([string]$config.productionCmakeFile) -BasePath $resolvedRoot
|
||||
$cmakeText = Get-Content -LiteralPath $cmakePath -Raw -Encoding UTF8
|
||||
$productionFiles = New-Object System.Collections.Generic.List[System.IO.FileInfo]
|
||||
foreach ($sourceRoot in $config.productionSourceRoots) {
|
||||
$sourceRootPath = Resolve-AbsolutePath -Path ([string]$sourceRoot) -BasePath $resolvedRoot
|
||||
foreach ($file in Get-ChildItem -LiteralPath $sourceRootPath -Recurse -File) {
|
||||
if ($config.productionExtensions -contains $file.Extension.ToLowerInvariant()) {
|
||||
$productionFiles.Add($file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$missingFromCmake = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $productionFiles) {
|
||||
$relativePath = Get-RelativePath -Root $resolvedRoot -Path $file.FullName
|
||||
if (-not $cmakeText.Contains($relativePath)) {
|
||||
$missingFromCmake.Add($relativePath)
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Production sources are registered in CMake" `
|
||||
-Passed ($missingFromCmake.Count -eq 0) `
|
||||
-Details ($(if ($missingFromCmake.Count -eq 0) { "$($productionFiles.Count) source/header files registered" } else { "Missing: " + ($missingFromCmake -join ", ") }))
|
||||
|
||||
$missingCmakeReferences = New-Object System.Collections.Generic.List[string]
|
||||
$cmakeReferencePattern = 'src[\\/][A-Za-z0-9_.\\/-]+\.(?:h|cpp)'
|
||||
foreach ($match in [regex]::Matches($cmakeText, $cmakeReferencePattern)) {
|
||||
$relativePath = $match.Value.Replace('\', '/')
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $resolvedRoot $relativePath) -PathType Leaf)) {
|
||||
$missingCmakeReferences.Add($relativePath)
|
||||
}
|
||||
}
|
||||
Add-Result -Name "CMake does not reference missing production sources" `
|
||||
-Passed ($missingCmakeReferences.Count -eq 0) `
|
||||
-Details ($(if ($missingCmakeReferences.Count -eq 0) { "No missing source references" } else { "Missing: " + (($missingCmakeReferences | Sort-Object -Unique) -join ", ") }))
|
||||
|
||||
$qaCmakePath = Resolve-AbsolutePath -Path ([string]$config.qaCmakeFile) -BasePath $resolvedRoot
|
||||
$qaRoot = Split-Path $qaCmakePath -Parent
|
||||
$qaCmakeText = Get-Content -LiteralPath $qaCmakePath -Raw -Encoding UTF8
|
||||
$missingQaTests = New-Object System.Collections.Generic.List[string]
|
||||
$qaTestCount = 0
|
||||
foreach ($testRoot in $config.qaTestRoots) {
|
||||
$testRootPath = Resolve-AbsolutePath -Path ([string]$testRoot) -BasePath $resolvedRoot
|
||||
foreach ($testFile in Get-ChildItem -LiteralPath $testRootPath -Recurse -Filter "*.cpp" -File) {
|
||||
++$qaTestCount
|
||||
$relativeTestPath = Get-RelativePath -Root $qaRoot -Path $testFile.FullName
|
||||
if (-not $qaCmakeText.Contains($relativeTestPath)) {
|
||||
$missingQaTests.Add($relativeTestPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Internal QA test sources are registered in the QA CMake project" `
|
||||
-Passed ($missingQaTests.Count -eq 0) `
|
||||
-Details ($(if ($missingQaTests.Count -eq 0) { "$qaTestCount test translation units registered" } else { "Missing: " + ($missingQaTests -join ", ") }))
|
||||
|
||||
$activeFiles = Get-TextFiles -Root $resolvedRoot -Paths $config.activeScanPaths
|
||||
foreach ($rule in $config.forbiddenReferences) {
|
||||
$matches = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $activeFiles) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
|
||||
if ($content -match $rule.pattern) {
|
||||
$matches.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
|
||||
}
|
||||
}
|
||||
Add-Result -Name "No $($rule.name)" `
|
||||
-Passed ($matches.Count -eq 0) `
|
||||
-Details ($(if ($matches.Count -eq 0) { "No active references" } else { "Found in: " + (($matches | Sort-Object -Unique) -join ", ") }))
|
||||
}
|
||||
|
||||
if ($config.forbidQObjectMacro) {
|
||||
$qObjectFiles = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($file in $productionFiles) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
|
||||
if ($content -match '\bQ_OBJECT\b') {
|
||||
$qObjectFiles.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName))
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Production code remains compatible with CMAKE_AUTOMOC OFF" `
|
||||
-Passed ($qObjectFiles.Count -eq 0) `
|
||||
-Details ($(if ($qObjectFiles.Count -eq 0) { "No Q_OBJECT macro found" } else { "Found in: " + ($qObjectFiles -join ", ") }))
|
||||
}
|
||||
|
||||
$missingRequiredPaths = @($config.requiredPaths | Where-Object {
|
||||
-not (Test-Path -LiteralPath (Resolve-AbsolutePath -Path ([string]$_) -BasePath $resolvedRoot))
|
||||
})
|
||||
Add-Result -Name "Required release inputs exist" `
|
||||
-Passed ($missingRequiredPaths.Count -eq 0) `
|
||||
-Details ($(if ($missingRequiredPaths.Count -eq 0) { "$($config.requiredPaths.Count) required paths found" } else { "Missing: " + ($missingRequiredPaths -join ", ") }))
|
||||
|
||||
$characterManifestPath = Join-Path $resolvedRoot "resources/characters/shiroko/character.json"
|
||||
$characterProblems = New-Object System.Collections.Generic.List[string]
|
||||
try {
|
||||
$character = Get-Content -LiteralPath $characterManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace([string]$character.defaultState)) {
|
||||
$characterProblems.Add("defaultState is empty")
|
||||
}
|
||||
foreach ($stateProperty in $character.states.PSObject.Properties) {
|
||||
$statePath = Join-Path (Split-Path $characterManifestPath -Parent) ([string]$stateProperty.Value.path)
|
||||
if (-not (Test-Path -LiteralPath $statePath -PathType Container)) {
|
||||
$characterProblems.Add("state directory missing: $($stateProperty.Name)")
|
||||
continue
|
||||
}
|
||||
if (@(Get-ChildItem -LiteralPath $statePath -Filter "*.png" -File).Count -eq 0) {
|
||||
$characterProblems.Add("state has no PNG frames: $($stateProperty.Name)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$characterProblems.Add("manifest parse failed: $($_.Exception.Message)")
|
||||
}
|
||||
Add-Result -Name "Default character manifest and frames are complete" `
|
||||
-Passed ($characterProblems.Count -eq 0) `
|
||||
-Details ($(if ($characterProblems.Count -eq 0) { "All declared states contain PNG frames" } else { $characterProblems -join "; " }))
|
||||
|
||||
$exportScriptPath = Join-Path $resolvedRoot "tools/prepare_github_export.ps1"
|
||||
$exportScriptText = Get-Content -LiteralPath $exportScriptPath -Raw -Encoding UTF8
|
||||
$missingExportExclusions = @($config.publicExportExcludedRoots | Where-Object {
|
||||
$quotedName = '"' + [regex]::Escape([string]$_) + '"'
|
||||
$exportScriptText -notmatch $quotedName
|
||||
})
|
||||
Add-Result -Name "Public GitHub export excludes internal development roots" `
|
||||
-Passed ($missingExportExclusions.Count -eq 0) `
|
||||
-Details ($(if ($missingExportExclusions.Count -eq 0) { "Excluded: " + ($config.publicExportExcludedRoots -join ", ") } else { "Not excluded: " + ($missingExportExclusions -join ", ") }))
|
||||
|
||||
$releaseIsolationMatches = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relativeFile in $config.releaseIsolationFiles) {
|
||||
$absoluteFile = Resolve-AbsolutePath -Path ([string]$relativeFile) -BasePath $resolvedRoot
|
||||
$content = Get-Content -LiteralPath $absoluteFile -Raw -Encoding UTF8
|
||||
foreach ($pattern in $config.releaseForbiddenPatterns) {
|
||||
if ($content -match $pattern) {
|
||||
$releaseIsolationMatches.Add("$relativeFile -> $pattern")
|
||||
}
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Production build and packaging do not depend on internal QA" `
|
||||
-Passed ($releaseIsolationMatches.Count -eq 0) `
|
||||
-Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " }))
|
||||
|
||||
$infrastructureProblems = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) {
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$scriptFile.FullName,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors) | Out-Null
|
||||
foreach ($parseError in $parseErrors) {
|
||||
$infrastructureProblems.Add("$($scriptFile.Name):$($parseError.Extent.StartLineNumber): $($parseError.Message)")
|
||||
}
|
||||
}
|
||||
foreach ($jsonFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/config") -Filter "*.json" -File) {
|
||||
try {
|
||||
Get-Content -LiteralPath $jsonFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null
|
||||
} catch {
|
||||
$infrastructureProblems.Add("$($jsonFile.Name): $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
Add-Result -Name "Internal QA scripts and configuration parse successfully" `
|
||||
-Passed ($infrastructureProblems.Count -eq 0) `
|
||||
-Details ($(if ($infrastructureProblems.Count -eq 0) { "All PowerShell and JSON files parsed" } else { $infrastructureProblems -join "; " }))
|
||||
|
||||
$gitResult = Invoke-GitDiffCheck -Root $resolvedRoot
|
||||
Add-Result -Name "Git diff whitespace check" `
|
||||
-Passed ($gitResult.ExitCode -eq 0) `
|
||||
-Details ($(if ($gitResult.ExitCode -eq 0) { "git diff --check passed" } else { $gitResult.Output }))
|
||||
|
||||
$passedCount = @($results | Where-Object Passed).Count
|
||||
$failedResults = @($results | Where-Object { -not $_.Passed })
|
||||
foreach ($result in $results) {
|
||||
$prefix = if ($result.Passed) { "PASS" } else { "FAIL" }
|
||||
Write-Host "[$prefix] $($result.Name) - $($result.Details)"
|
||||
}
|
||||
Write-Host "Static checks: $passedCount passed, $($failedResults.Count) failed."
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ReportPath)) {
|
||||
$resolvedReportPath = Resolve-AbsolutePath -Path $ReportPath -BasePath $resolvedRoot
|
||||
$reportDirectory = Split-Path $resolvedReportPath -Parent
|
||||
if (-not (Test-Path -LiteralPath $reportDirectory)) {
|
||||
New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null
|
||||
}
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add("# Static Check Report")
|
||||
$lines.Add("")
|
||||
$lines.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
|
||||
$lines.Add("")
|
||||
$lines.Add("| Check | Result | Details |")
|
||||
$lines.Add("| --- | --- | --- |")
|
||||
foreach ($result in $results) {
|
||||
$status = if ($result.Passed) { "PASS" } else { "FAIL" }
|
||||
$details = ([string]$result.Details).Replace("|", "\|").Replace("`r", " ").Replace("`n", " ")
|
||||
$lines.Add("| $($result.Name) | $status | $details |")
|
||||
}
|
||||
Set-Content -LiteralPath $resolvedReportPath -Value $lines -Encoding UTF8
|
||||
Write-Host "Report: $resolvedReportPath"
|
||||
}
|
||||
|
||||
if ($failedResults.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputPath,
|
||||
[string]$Scenario = "",
|
||||
[string]$ThresholdConfigPath = "",
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-AbsolutePath {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$BasePath
|
||||
)
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
|
||||
}
|
||||
|
||||
function Convert-ToDouble {
|
||||
param([object]$Value)
|
||||
return [double]::Parse(
|
||||
[string]$Value,
|
||||
[System.Globalization.NumberStyles]::Float,
|
||||
[System.Globalization.CultureInfo]::InvariantCulture)
|
||||
}
|
||||
|
||||
function Get-Percentile {
|
||||
param(
|
||||
[double[]]$Values,
|
||||
[double]$Percentile
|
||||
)
|
||||
if ($Values.Count -eq 0) { return 0.0 }
|
||||
$sorted = @($Values | Sort-Object)
|
||||
$index = [math]::Max(0, [math]::Ceiling($Percentile * $sorted.Count) - 1)
|
||||
return [double]$sorted[$index]
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string]$Name
|
||||
)
|
||||
if ($null -eq $Object) { return $null }
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $property) { return $null }
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||
$resolvedInput = Resolve-AbsolutePath -Path $InputPath -BasePath $repoRoot
|
||||
if (-not (Test-Path -LiteralPath $resolvedInput -PathType Leaf)) {
|
||||
throw "Performance CSV was not found: $resolvedInput"
|
||||
}
|
||||
|
||||
$resolvedThresholds = if ([string]::IsNullOrWhiteSpace($ThresholdConfigPath)) {
|
||||
Join-Path $repoRoot "internal/qa/config/performance-thresholds.json"
|
||||
} else {
|
||||
Resolve-AbsolutePath -Path $ThresholdConfigPath -BasePath $repoRoot
|
||||
}
|
||||
$thresholdConfig = Get-Content -LiteralPath $resolvedThresholds -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($thresholdConfig.schemaVersion -ne 1) {
|
||||
throw "Unsupported performance threshold configuration version: $($thresholdConfig.schemaVersion)"
|
||||
}
|
||||
|
||||
$allRows = @(Import-Csv -LiteralPath $resolvedInput)
|
||||
$runningRows = @($allRows | Where-Object { $_.Status -eq "running" })
|
||||
if ($runningRows.Count -eq 0) {
|
||||
throw "Performance CSV contains no running samples: $resolvedInput"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Scenario)) {
|
||||
$scenarioProperty = $runningRows[0].PSObject.Properties["Scenario"]
|
||||
$Scenario = if ($null -ne $scenarioProperty -and -not [string]::IsNullOrWhiteSpace([string]$scenarioProperty.Value)) {
|
||||
[string]$scenarioProperty.Value
|
||||
} else {
|
||||
[System.IO.Path]::GetFileNameWithoutExtension($resolvedInput)
|
||||
}
|
||||
}
|
||||
|
||||
$cpuValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.CpuPercent })
|
||||
$workingSetValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.WorkingSetMB })
|
||||
$privateMemoryValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.PrivateMemoryMB })
|
||||
$handleValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.HandleCount })
|
||||
$threadValues = [double[]]@($runningRows | ForEach-Object { Convert-ToDouble $_.ThreadCount })
|
||||
|
||||
$metrics = [ordered]@{
|
||||
SampleCount = $runningRows.Count
|
||||
AverageCpuPercent = [math]::Round(($cpuValues | Measure-Object -Average).Average, 2)
|
||||
P95CpuPercent = [math]::Round((Get-Percentile -Values $cpuValues -Percentile 0.95), 2)
|
||||
PeakCpuPercent = [math]::Round(($cpuValues | Measure-Object -Maximum).Maximum, 2)
|
||||
StartWorkingSetMB = $workingSetValues[0]
|
||||
EndWorkingSetMB = $workingSetValues[-1]
|
||||
PeakWorkingSetMB = [math]::Round(($workingSetValues | Measure-Object -Maximum).Maximum, 2)
|
||||
WorkingSetGrowthMB = [math]::Round($workingSetValues[-1] - $workingSetValues[0], 2)
|
||||
StartPrivateMemoryMB = $privateMemoryValues[0]
|
||||
EndPrivateMemoryMB = $privateMemoryValues[-1]
|
||||
PeakPrivateMemoryMB = [math]::Round(($privateMemoryValues | Measure-Object -Maximum).Maximum, 2)
|
||||
PrivateMemoryGrowthMB = [math]::Round($privateMemoryValues[-1] - $privateMemoryValues[0], 2)
|
||||
HandleGrowth = [int]($handleValues[-1] - $handleValues[0])
|
||||
PeakHandleCount = [int](($handleValues | Measure-Object -Maximum).Maximum)
|
||||
ThreadGrowth = [int]($threadValues[-1] - $threadValues[0])
|
||||
PeakThreadCount = [int](($threadValues | Measure-Object -Maximum).Maximum)
|
||||
UnresponsiveSamples = @($runningRows | Where-Object { ([string]$_.Responding).ToLowerInvariant() -eq "false" }).Count
|
||||
UnexpectedExit = @($allRows | Where-Object { $_.Status -eq "exited" }).Count -gt 0
|
||||
}
|
||||
|
||||
$scenarioThresholds = Get-PropertyValue -Object $thresholdConfig.scenarios -Name $Scenario
|
||||
$effectiveThresholds = @{}
|
||||
foreach ($property in $thresholdConfig.defaults.PSObject.Properties) {
|
||||
$effectiveThresholds[$property.Name] = $property.Value
|
||||
}
|
||||
if ($null -ne $scenarioThresholds) {
|
||||
foreach ($property in $scenarioThresholds.PSObject.Properties) {
|
||||
$effectiveThresholds[$property.Name] = $property.Value
|
||||
}
|
||||
}
|
||||
|
||||
$checks = New-Object System.Collections.Generic.List[object]
|
||||
function Add-ThresholdCheck {
|
||||
param(
|
||||
[string]$Name,
|
||||
[double]$Actual,
|
||||
[string]$ThresholdName,
|
||||
[string]$Unit
|
||||
)
|
||||
$threshold = $effectiveThresholds[$ThresholdName]
|
||||
if ($null -eq $threshold) { return }
|
||||
$limit = Convert-ToDouble $threshold
|
||||
$checks.Add([pscustomobject]@{
|
||||
Name = $Name
|
||||
Passed = $Actual -le $limit
|
||||
Actual = "$Actual$Unit"
|
||||
Limit = "<= $limit$Unit"
|
||||
})
|
||||
}
|
||||
|
||||
$allowUnexpectedExit = [bool]$effectiveThresholds["allowUnexpectedExit"]
|
||||
$checks.Add([pscustomobject]@{
|
||||
Name = "Unexpected process exit"
|
||||
Passed = $allowUnexpectedExit -or -not $metrics.UnexpectedExit
|
||||
Actual = [string]$metrics.UnexpectedExit
|
||||
Limit = if ($allowUnexpectedExit) { "allowed" } else { "False" }
|
||||
})
|
||||
Add-ThresholdCheck -Name "Unresponsive samples" -Actual $metrics.UnresponsiveSamples -ThresholdName "maxUnresponsiveSamples" -Unit ""
|
||||
Add-ThresholdCheck -Name "Average CPU" -Actual $metrics.AverageCpuPercent -ThresholdName "maxAverageCpuPercent" -Unit "%"
|
||||
Add-ThresholdCheck -Name "P95 CPU" -Actual $metrics.P95CpuPercent -ThresholdName "maxP95CpuPercent" -Unit "%"
|
||||
Add-ThresholdCheck -Name "Private memory growth" -Actual $metrics.PrivateMemoryGrowthMB -ThresholdName "maxPrivateMemoryGrowthMB" -Unit " MB"
|
||||
Add-ThresholdCheck -Name "Working set growth" -Actual $metrics.WorkingSetGrowthMB -ThresholdName "maxWorkingSetGrowthMB" -Unit " MB"
|
||||
Add-ThresholdCheck -Name "Handle growth" -Actual $metrics.HandleGrowth -ThresholdName "maxHandleGrowth" -Unit ""
|
||||
Add-ThresholdCheck -Name "Thread growth" -Actual $metrics.ThreadGrowth -ThresholdName "maxThreadGrowth" -Unit ""
|
||||
|
||||
$failedChecks = @($checks | Where-Object { -not $_.Passed })
|
||||
$result = if ($failedChecks.Count -eq 0) { "PASS" } else { "FAIL" }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$safeScenario = $Scenario -replace '[^a-zA-Z0-9._-]', '_'
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$OutputPath = "internal/qa/out/performance/$timestamp-$safeScenario.md"
|
||||
}
|
||||
$resolvedOutput = Resolve-AbsolutePath -Path $OutputPath -BasePath $repoRoot
|
||||
$outputDirectory = Split-Path $resolvedOutput -Parent
|
||||
if (-not (Test-Path -LiteralPath $outputDirectory)) {
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add("# Performance Summary")
|
||||
$lines.Add("")
|
||||
$lines.Add("- Scenario: ``$Scenario``")
|
||||
$lines.Add("- Result: **$result**")
|
||||
$lines.Add("- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')")
|
||||
$lines.Add("- Source CSV: ``$resolvedInput``")
|
||||
$lines.Add("")
|
||||
$lines.Add("## Metrics")
|
||||
$lines.Add("")
|
||||
$lines.Add("| Metric | Value |")
|
||||
$lines.Add("| --- | ---: |")
|
||||
foreach ($entry in $metrics.GetEnumerator()) {
|
||||
$lines.Add("| $($entry.Key) | $($entry.Value) |")
|
||||
}
|
||||
$lines.Add("")
|
||||
$lines.Add("## Threshold Checks")
|
||||
$lines.Add("")
|
||||
$lines.Add("| Check | Result | Actual | Limit |")
|
||||
$lines.Add("| --- | --- | ---: | ---: |")
|
||||
foreach ($check in $checks) {
|
||||
$status = if ($check.Passed) { "PASS" } else { "FAIL" }
|
||||
$lines.Add("| $($check.Name) | $status | $($check.Actual) | $($check.Limit) |")
|
||||
}
|
||||
$lines.Add("")
|
||||
$lines.Add("Resource thresholds left as ``null`` are reported but not used as release gates. Calibrate them after a reviewed Release baseline is available.")
|
||||
|
||||
Set-Content -LiteralPath $resolvedOutput -Value $lines -Encoding UTF8
|
||||
Write-Host "Performance report: $resolvedOutput"
|
||||
Write-Host "Result: $result"
|
||||
|
||||
if ($failedChecks.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
@@ -6,6 +6,7 @@ param(
|
||||
[int]$IntervalSeconds = 5,
|
||||
[ValidateRange(1, 604800)]
|
||||
[int]$DurationSeconds = 300,
|
||||
[string]$Scenario = "unspecified",
|
||||
[string]$OutputPath = "reports/perf"
|
||||
)
|
||||
|
||||
@@ -35,25 +36,13 @@ function Resolve-TargetProcess {
|
||||
}
|
||||
|
||||
function Read-ProcessSnapshot {
|
||||
param(
|
||||
[System.Diagnostics.Process]$Process
|
||||
)
|
||||
param([System.Diagnostics.Process]$Process)
|
||||
|
||||
$path = ""
|
||||
try {
|
||||
$path = $Process.Path
|
||||
}
|
||||
catch {
|
||||
$path = ""
|
||||
}
|
||||
try { $path = $Process.Path } catch { $path = "" }
|
||||
|
||||
$responding = ""
|
||||
try {
|
||||
$responding = $Process.Responding
|
||||
}
|
||||
catch {
|
||||
$responding = ""
|
||||
}
|
||||
try { $responding = $Process.Responding } catch { $responding = "" }
|
||||
|
||||
return [pscustomobject]@{
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
@@ -68,6 +57,16 @@ function Read-ProcessSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-OutputDirectory {
|
||||
param([string]$Path)
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
|
||||
}
|
||||
|
||||
function New-OutputFilePath {
|
||||
param(
|
||||
[string]$Directory,
|
||||
@@ -87,27 +86,25 @@ $target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
|
||||
if ($null -eq $target) {
|
||||
if ($ProcessId -gt 0) {
|
||||
Write-Error "Process with PID $ProcessId was not found."
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
$logicalProcessorCount = [Environment]::ProcessorCount
|
||||
if ($logicalProcessorCount -lt 1) {
|
||||
$logicalProcessorCount = 1
|
||||
}
|
||||
|
||||
$outputFile = New-OutputFilePath -Directory $OutputPath -Name $target.ProcessName
|
||||
$logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount)
|
||||
$outputDirectory = Resolve-OutputDirectory -Path $OutputPath
|
||||
$outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)"
|
||||
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
|
||||
|
||||
Write-Host "Sampling PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
||||
Write-Host "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
||||
Write-Host "Output: $outputFile"
|
||||
|
||||
$previousSnapshot = Read-ProcessSnapshot -Process $target
|
||||
$previousTime = Get-Date
|
||||
$firstRow = [pscustomobject]@{
|
||||
Scenario = $Scenario
|
||||
SampleIndex = 0
|
||||
TimestampUtc = $previousSnapshot.TimestampUtc
|
||||
Pid = $previousSnapshot.Pid
|
||||
CpuPercent = 0
|
||||
@@ -127,6 +124,8 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
||||
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
|
||||
if ($null -eq $currentProcess) {
|
||||
[pscustomobject]@{
|
||||
Scenario = $Scenario
|
||||
SampleIndex = $index
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Pid = $target.Id
|
||||
CpuPercent = ""
|
||||
@@ -147,11 +146,11 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
||||
$currentTime = Get-Date
|
||||
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
|
||||
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
|
||||
if ($cpuPercent -lt 0) {
|
||||
$cpuPercent = 0
|
||||
}
|
||||
if ($cpuPercent -lt 0) { $cpuPercent = 0 }
|
||||
|
||||
[pscustomobject]@{
|
||||
Scenario = $Scenario
|
||||
SampleIndex = $index
|
||||
TimestampUtc = $currentSnapshot.TimestampUtc
|
||||
Pid = $currentSnapshot.Pid
|
||||
CpuPercent = [math]::Round($cpuPercent, 2)
|
||||
@@ -168,4 +167,4 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
||||
$previousTime = $currentTime
|
||||
}
|
||||
|
||||
Write-Host "Sampling finished."
|
||||
Write-Host "Sampling finished: $outputFile"
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QStandardPaths>
|
||||
#include <QTextStream>
|
||||
|
||||
#include <exception>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QStandardPaths::setTestModeEnabled(true);
|
||||
QCoreApplication application(argc, argv);
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("QtDesktopPetInternalQA"));
|
||||
QCoreApplication::setApplicationName(QStringLiteral("CoreLogicTests"));
|
||||
|
||||
QTextStream output(stdout);
|
||||
QTextStream errors(stderr);
|
||||
int failedCount = 0;
|
||||
|
||||
for (const Qa::TestCase &test : Qa::registry())
|
||||
{
|
||||
try
|
||||
{
|
||||
test.function();
|
||||
output << "[PASS] " << QString::fromStdString(test.name) << Qt::endl;
|
||||
}
|
||||
catch (const std::exception &exception)
|
||||
{
|
||||
++failedCount;
|
||||
errors << "[FAIL] " << QString::fromStdString(test.name)
|
||||
<< ": " << exception.what() << Qt::endl;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
++failedCount;
|
||||
errors << "[FAIL] " << QString::fromStdString(test.name)
|
||||
<< ": unknown exception" << Qt::endl;
|
||||
}
|
||||
}
|
||||
|
||||
output << "Executed " << Qa::registry().size() << " tests; "
|
||||
<< failedCount << " failed." << Qt::endl;
|
||||
return failedCount == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/ai/ConversationStore.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTime>
|
||||
|
||||
namespace
|
||||
{
|
||||
ChatMessage message(const QString &role, const QString &content, int minute)
|
||||
{
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
QDateTime(QDate(2026, 7, 17), QTime(10, minute)),
|
||||
QStringLiteral("openai"),
|
||||
QStringLiteral("test-model"),
|
||||
};
|
||||
}
|
||||
|
||||
bool writeBytes(const QString &filePath, const QByteArray &bytes)
|
||||
{
|
||||
QFile file(filePath);
|
||||
return file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||
&& file.write(bytes) == bytes.size();
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, SavesLoadsAndLimitsEvenHistory)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const QString filePath = directory.filePath(QStringLiteral("conversation_history.json"));
|
||||
const ConversationStore store(filePath);
|
||||
const QVector<ChatMessage> messages = {
|
||||
message(QStringLiteral("user"), QStringLiteral("one"), 0),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("two"), 1),
|
||||
message(QStringLiteral("user"), QStringLiteral("three"), 2),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("four"), 3),
|
||||
};
|
||||
|
||||
QA_EXPECT(store.save(messages, 4));
|
||||
const QVector<ChatMessage> loaded = store.load(3);
|
||||
QA_EXPECT_EQ(loaded.size(), 2);
|
||||
QA_EXPECT_EQ(loaded.first().content, QStringLiteral("three"));
|
||||
QA_EXPECT_EQ(loaded.last().model, QStringLiteral("test-model"));
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, AvoidsDuplicatingOverlappingMessages)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const ConversationStore store(directory.filePath(QStringLiteral("history.json")));
|
||||
const QVector<ChatMessage> firstPair = {
|
||||
message(QStringLiteral("user"), QStringLiteral("hello"), 0),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("world"), 1),
|
||||
};
|
||||
|
||||
QA_EXPECT(store.save(firstPair, 10));
|
||||
QA_EXPECT(store.save(firstPair, 10));
|
||||
QA_EXPECT_EQ(store.load(10).size(), 2);
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, LoadsLegacyCreatedAtField)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const QString filePath = directory.filePath(QStringLiteral("legacy.json"));
|
||||
QJsonArray messages;
|
||||
messages.append(QJsonObject{
|
||||
{QStringLiteral("role"), QStringLiteral("user")},
|
||||
{QStringLiteral("content"), QStringLiteral("legacy-user")},
|
||||
{QStringLiteral("createdAt"), QStringLiteral("2026-07-17T10:00:00")},
|
||||
});
|
||||
messages.append(QJsonObject{
|
||||
{QStringLiteral("role"), QStringLiteral("assistant")},
|
||||
{QStringLiteral("content"), QStringLiteral("legacy-assistant")},
|
||||
{QStringLiteral("createdAt"), QStringLiteral("2026-07-17T10:00:01")},
|
||||
});
|
||||
QA_EXPECT(writeBytes(filePath, QJsonDocument(QJsonObject{{QStringLiteral("messages"), messages}}).toJson()));
|
||||
|
||||
const QVector<ChatMessage> loaded = ConversationStore(filePath).load(10);
|
||||
QA_EXPECT_EQ(loaded.size(), 2);
|
||||
QA_EXPECT(loaded.first().timestamp.isValid());
|
||||
QA_EXPECT(loaded.first().provider.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(ConversationStore, BacksUpBrokenJsonAndClearsHistory)
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QA_EXPECT(directory.isValid());
|
||||
const QString filePath = directory.filePath(QStringLiteral("conversation_history.json"));
|
||||
QA_EXPECT(writeBytes(filePath, QByteArrayLiteral("{broken")));
|
||||
|
||||
QString errorMessage;
|
||||
const ConversationStore store(filePath);
|
||||
QA_EXPECT(store.load(10, &errorMessage).isEmpty());
|
||||
QA_EXPECT(!errorMessage.isEmpty());
|
||||
QA_EXPECT(!QFile::exists(filePath));
|
||||
QA_EXPECT_EQ(
|
||||
QDir(directory.path()).entryList({QStringLiteral("conversation_history.broken.*.json")}, QDir::Files).size(),
|
||||
1);
|
||||
|
||||
QA_EXPECT(store.save({
|
||||
message(QStringLiteral("user"), QStringLiteral("hello"), 0),
|
||||
message(QStringLiteral("assistant"), QStringLiteral("world"), 1),
|
||||
}, 10));
|
||||
QA_EXPECT(store.clear());
|
||||
QA_EXPECT(!QFile::exists(filePath));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/assistant/CommandDispatcher.h"
|
||||
#include "src/assistant/IntentRouter.h"
|
||||
|
||||
QA_TEST(IntentRouting, TrimsChatText)
|
||||
{
|
||||
const UserIntent intent = IntentRouter().route(QStringLiteral(" 你好 "));
|
||||
QA_EXPECT_EQ(intent.type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(intent.text, QStringLiteral("你好"));
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, ReminderHasPriorityOverWeather)
|
||||
{
|
||||
const UserIntent intent = IntentRouter().route(QStringLiteral("明天提醒我看天气"));
|
||||
QA_EXPECT_EQ(intent.type, UserIntentType::Reminder);
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, RecognizesWeatherExpressions)
|
||||
{
|
||||
const IntentRouter router;
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("西安天气怎么样")).type, UserIntentType::Weather);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("明天会不会下雨")).type, UserIntentType::Weather);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("外面冷不冷")).type, UserIntentType::Weather);
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, RemovedLocalCapabilitiesRemainChat)
|
||||
{
|
||||
const IntentRouter router;
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("读取文件")).type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("打开 Codex")).type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("分析这个项目")).type, UserIntentType::Chat);
|
||||
QA_EXPECT_EQ(router.route(QStringLiteral("创建 test.txt")).type, UserIntentType::Chat);
|
||||
}
|
||||
|
||||
QA_TEST(IntentRouting, DispatcherMapsSupportedActions)
|
||||
{
|
||||
const CommandDispatcher dispatcher;
|
||||
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("提醒我休息")).action, CommandDispatchAction::Reminder);
|
||||
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("北京天气")).action, CommandDispatchAction::Weather);
|
||||
QA_EXPECT_EQ(dispatcher.dispatch(QStringLiteral("讲个笑话")).action, CommandDispatchAction::Chat);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/reminder/ReminderParser.h"
|
||||
#include "src/reminder/ReminderTypes.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QStringList>
|
||||
#include <QTime>
|
||||
|
||||
namespace
|
||||
{
|
||||
QDateTime fixedNow()
|
||||
{
|
||||
return QDateTime(QDate(2026, 7, 17), QTime(14, 0));
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesRelativeMinutes)
|
||||
{
|
||||
const ReminderCommand command = ReminderParser().parse(QStringLiteral("10分钟后提醒我喝水"), fixedNow());
|
||||
QA_EXPECT_EQ(command.type, ReminderCommandType::Create);
|
||||
QA_EXPECT_EQ(command.remindAt, fixedNow().addSecs(10 * 60));
|
||||
QA_EXPECT_EQ(command.title, QStringLiteral("喝水"));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesHalfHours)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
QA_EXPECT_EQ(
|
||||
parser.parse(QStringLiteral("半小时后提醒我休息"), fixedNow()).remindAt,
|
||||
fixedNow().addSecs(30 * 60));
|
||||
QA_EXPECT_EQ(
|
||||
parser.parse(QStringLiteral("一个半小时后提醒我喝水"), fixedNow()).remindAt,
|
||||
fixedNow().addSecs(90 * 60));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesTomorrowClockTime)
|
||||
{
|
||||
const ReminderCommand command = ReminderParser().parse(QStringLiteral("明天9点提醒我开会"), fixedNow());
|
||||
QA_EXPECT_EQ(command.type, ReminderCommandType::Create);
|
||||
QA_EXPECT_EQ(command.remindAt, QDateTime(QDate(2026, 7, 18), QTime(9, 0)));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesSupportedRecurrence)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
const ReminderCommand daily = parser.parse(QStringLiteral("每天9点提醒我打卡"), fixedNow());
|
||||
const ReminderCommand weekly = parser.parse(QStringLiteral("每周一上午10点提醒我周会"), fixedNow());
|
||||
const ReminderCommand monthly = parser.parse(QStringLiteral("每月31号9点提醒我交报告"), fixedNow());
|
||||
|
||||
QA_EXPECT_EQ(daily.recurrence.type, ReminderRecurrenceType::Daily);
|
||||
QA_EXPECT_EQ(daily.remindAt, QDateTime(QDate(2026, 7, 18), QTime(9, 0)));
|
||||
QA_EXPECT_EQ(weekly.recurrence.type, ReminderRecurrenceType::Weekly);
|
||||
QA_EXPECT_EQ(weekly.recurrence.weekday, 1);
|
||||
QA_EXPECT_EQ(monthly.recurrence.type, ReminderRecurrenceType::Monthly);
|
||||
QA_EXPECT_EQ(monthly.recurrence.monthDay, 31);
|
||||
QA_EXPECT_EQ(monthly.remindAt, QDateTime(QDate(2026, 7, 31), QTime(9, 0)));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, RejectsUnsupportedRecurrence)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
const QStringList requests = {
|
||||
QStringLiteral("工作日9点提醒我打卡"),
|
||||
QStringLiteral("每两天提醒我喝水"),
|
||||
QStringLiteral("农历初一提醒我"),
|
||||
QStringLiteral("每月最后一天9点提醒我结算"),
|
||||
};
|
||||
|
||||
for (const QString &request : requests)
|
||||
{
|
||||
const ReminderCommand command = parser.parse(request, fixedNow());
|
||||
QA_EXPECT_EQ(command.type, ReminderCommandType::Invalid);
|
||||
QA_EXPECT(!command.errorMessage.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(ReminderParser, ParsesListAndCancelCommands)
|
||||
{
|
||||
const ReminderParser parser;
|
||||
QA_EXPECT_EQ(parser.parse(QStringLiteral("提醒列表"), fixedNow()).type, ReminderCommandType::List);
|
||||
|
||||
const ReminderCommand cancel = parser.parse(QStringLiteral("取消喝水提醒"), fixedNow());
|
||||
QA_EXPECT_EQ(cancel.type, ReminderCommandType::Cancel);
|
||||
QA_EXPECT_EQ(cancel.cancelQuery, QStringLiteral("喝水"));
|
||||
}
|
||||
|
||||
QA_TEST(ReminderTypes, ConvertsAndSortsValues)
|
||||
{
|
||||
QA_EXPECT_EQ(reminderStatusFromString(QStringLiteral("TRIGGERED")), ReminderStatus::Triggered);
|
||||
QA_EXPECT_EQ(reminderRecurrenceTypeFromString(QStringLiteral("weekly")), ReminderRecurrenceType::Weekly);
|
||||
QA_EXPECT_EQ(reminderRecurrenceTypeToString(ReminderRecurrenceType::Monthly), QStringLiteral("monthly"));
|
||||
|
||||
ReminderItem later;
|
||||
later.id = QStringLiteral("later");
|
||||
later.remindAt = fixedNow().addSecs(60);
|
||||
ReminderItem earlier;
|
||||
earlier.id = QStringLiteral("earlier");
|
||||
earlier.remindAt = fixedNow();
|
||||
|
||||
const QVector<ReminderItem> sorted = sortedReminders({later, earlier});
|
||||
QA_EXPECT_EQ(sorted.first().id, QStringLiteral("earlier"));
|
||||
QA_EXPECT_EQ(sorted.last().id, QStringLiteral("later"));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/weather/WeatherParser.h"
|
||||
#include "src/weather/WeatherSummaryFormatter.h"
|
||||
|
||||
QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
const WeatherQuery current = parser.parse(QStringLiteral("西安天气怎么样"));
|
||||
const WeatherQuery tomorrow = parser.parse(QStringLiteral("明天西安天气怎么样"));
|
||||
const WeatherQuery afterTomorrow = parser.parse(QStringLiteral("后天纽约天气"));
|
||||
|
||||
QA_EXPECT_EQ(current.cityName, QStringLiteral("西安"));
|
||||
QA_EXPECT_EQ(current.kind, WeatherQueryKind::Current);
|
||||
QA_EXPECT_EQ(tomorrow.cityName, QStringLiteral("西安"));
|
||||
QA_EXPECT_EQ(tomorrow.dateOffset, 1);
|
||||
QA_EXPECT_EQ(afterTomorrow.cityName, QStringLiteral("纽约"));
|
||||
QA_EXPECT_EQ(afterTomorrow.dateOffset, 2);
|
||||
}
|
||||
|
||||
QA_TEST(WeatherParser, ParsesRangeAndDefaultCityRequest)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
const WeatherQuery range = parser.parse(QStringLiteral("未来三天北京天气"));
|
||||
const WeatherQuery defaultCity = parser.parse(QStringLiteral("今天天气怎么样"));
|
||||
|
||||
QA_EXPECT_EQ(range.kind, WeatherQueryKind::Range);
|
||||
QA_EXPECT_EQ(range.forecastDays, 3);
|
||||
QA_EXPECT_EQ(range.cityName, QStringLiteral("北京"));
|
||||
QA_EXPECT(defaultCity.cityName.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(WeatherParser, RejectsUnsupportedQueries)
|
||||
{
|
||||
const WeatherParser parser;
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("西安空气质量")).unsupportedReason.isEmpty());
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("北京天气预警")).unsupportedReason.isEmpty());
|
||||
QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty());
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections)
|
||||
{
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(95), QStringLiteral("雷暴"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(-1), QStringLiteral("未知天气"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(0), QStringLiteral("北风"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(90), QStringLiteral("东风"));
|
||||
QA_EXPECT_EQ(WeatherSummaryFormatter::windDirectionText(225), QStringLiteral("西南风"));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, FormatsCurrentWeatherWithSource)
|
||||
{
|
||||
WeatherReport report;
|
||||
report.query.kind = WeatherQueryKind::Current;
|
||||
report.location.cityName = QStringLiteral("西安");
|
||||
report.location.countryName = QStringLiteral("中国");
|
||||
report.location.source = WeatherLocationSource::SettingsDefault;
|
||||
report.current.valid = true;
|
||||
report.current.weatherCode = 0;
|
||||
report.current.hasTemperature = true;
|
||||
report.current.temperatureC = 28.5;
|
||||
|
||||
const QString message = WeatherSummaryFormatter().format(report);
|
||||
QA_EXPECT(message.contains(QStringLiteral("使用设置页默认城市")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("西安,中国当前天气:晴")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("28.5℃")));
|
||||
}
|
||||
|
||||
QA_TEST(WeatherFormatter, FormatsAmbiguousLocationAndMissingData)
|
||||
{
|
||||
WeatherReport report;
|
||||
report.query.kind = WeatherQueryKind::Current;
|
||||
report.location.cityName = QStringLiteral("Springfield");
|
||||
report.location.countryName = QStringLiteral("United States");
|
||||
report.hasLocationAmbiguity = true;
|
||||
report.locationCandidates = {
|
||||
{QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), {}, 0.0, 0.0},
|
||||
{QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), {}, 0.0, 0.0},
|
||||
};
|
||||
|
||||
const QString message = WeatherSummaryFormatter().format(report);
|
||||
QA_EXPECT(message.contains(QStringLiteral("可能存在同名城市")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("Missouri")));
|
||||
QA_EXPECT(message.contains(QStringLiteral("缺少当前天气")));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "TestHarness.h"
|
||||
|
||||
#include "src/config/AIConfig.h"
|
||||
#include "src/web/WebCapabilityDetector.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
AIConfig completeConfig(const QString &provider)
|
||||
{
|
||||
AIConfig config = defaultAIConfigForProvider(provider);
|
||||
config.model = QStringLiteral("test-model");
|
||||
config.apiKey = QStringLiteral("test-key-not-a-real-secret");
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RespectsGlobalDisable)
|
||||
{
|
||||
WebConfig webConfig;
|
||||
webConfig.enabled = false;
|
||||
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("openai")), webConfig);
|
||||
QA_EXPECT(!capability.supported);
|
||||
QA_EXPECT(capability.statusText.contains(QStringLiteral("已关闭")));
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, AcceptsOfficialOpenAI)
|
||||
{
|
||||
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("openai")));
|
||||
QA_EXPECT(capability.supported);
|
||||
QA_EXPECT_EQ(capability.kind, WebProviderKind::OpenAIResponses);
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RejectsThirdPartyOpenAIBaseUrl)
|
||||
{
|
||||
AIConfig config = completeConfig(QStringLiteral("openai"));
|
||||
config.baseUrl = QStringLiteral("https://relay.example.com/v1");
|
||||
const WebCapability capability = WebCapabilityDetector::detect(config);
|
||||
QA_EXPECT(!capability.supported);
|
||||
QA_EXPECT(capability.userMessage.contains(QStringLiteral("api.openai.com")));
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, AcceptsGeminiProtocol)
|
||||
{
|
||||
const WebCapability capability = WebCapabilityDetector::detect(completeConfig(QStringLiteral("google")));
|
||||
QA_EXPECT(capability.supported);
|
||||
QA_EXPECT_EQ(capability.kind, WebProviderKind::GeminiGrounding);
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RejectsDeepSeekAndCustomProviders)
|
||||
{
|
||||
const WebCapability deepSeek = WebCapabilityDetector::detect(completeConfig(QStringLiteral("deepseek")));
|
||||
const WebCapability custom = WebCapabilityDetector::detect(completeConfig(QStringLiteral("custom")));
|
||||
QA_EXPECT(!deepSeek.supported);
|
||||
QA_EXPECT(deepSeek.statusText.contains(QStringLiteral("DeepSeek")));
|
||||
QA_EXPECT(!custom.supported);
|
||||
QA_EXPECT(custom.statusText.contains(QStringLiteral("无法确认")));
|
||||
}
|
||||
|
||||
QA_TEST(WebCapability, RequiresCompleteConfiguration)
|
||||
{
|
||||
AIConfig config = defaultAIConfigForProvider(QStringLiteral("openai"));
|
||||
const WebCapability capability = WebCapabilityDetector::detect(config);
|
||||
QA_EXPECT(!capability.supported);
|
||||
QA_EXPECT(capability.statusText.contains(QStringLiteral("未配置完整")));
|
||||
}
|
||||
@@ -16,14 +16,7 @@ ConversationManager::ConversationManager()
|
||||
"3. 查询天气:\n"
|
||||
" - 例:“西安天气怎么样”“明天北京天气”“未来三天上海天气”。\n"
|
||||
" - 建议用户使用市级城市名;区县、乡镇、街道不保证精确。\n"
|
||||
"4. 本地文件操作:\n"
|
||||
" - 例:“读取文件”“列出文件夹”“复制文件”“备份文件”“重命名文件”。\n"
|
||||
" - 文件和文件夹必须由用户在系统选择框中主动选择。\n"
|
||||
" - 不支持删除、覆盖、移动、执行脚本、运行命令或 zip 打包。\n"
|
||||
"5. 启动本地应用:\n"
|
||||
" - 例:“打开 Codex”“启动酷狗音乐”“打开 VSCode”。\n"
|
||||
" - 启动前需要用户确认;不会执行聊天文本里的命令或脚本。\n"
|
||||
"6. 联网模式:\n"
|
||||
"4. 联网模式:\n"
|
||||
" - 用户需要打开输入框里的“联网”开关。\n"
|
||||
" - 只有当前 AI 配置支持原生联网时才可用,例如 OpenAI 官方联网能力或 Gemini Google Search grounding。\n"
|
||||
" - DeepSeek 官方 API、第三方 OpenAI-Compatible API 通常不具备可确认的原生联网能力;遇到这类配置时,应明确提醒用户当前配置无法联网,不要假装已经搜索。\n"
|
||||
@@ -31,10 +24,10 @@ ConversationManager::ConversationManager()
|
||||
"回答规则:\n"
|
||||
"- 如果用户只是闲聊,直接自然回复。\n"
|
||||
"- 如果用户问“你能做什么”,用简短列表说明上述功能和示例。\n"
|
||||
"- 如果用户要做提醒、天气、文件操作或打开应用,可以直接提示用户按示例输入。\n"
|
||||
"- 如果用户请求危险操作,例如删除文件、覆盖文件、执行命令、运行脚本、提权操作,应明确拒绝,并给出安全替代方案。\n"
|
||||
"- 如果用户要创建提醒或查询天气,可以直接提示用户按示例输入。\n"
|
||||
"- 当前应用不能读取、搜索、创建、修改或删除本地文件,不能启动本地应用,也不能执行命令或脚本;遇到这类请求要明确说明能力边界。\n"
|
||||
"- 如果用户询问最新信息但没有开启联网,提醒用户打开“联网”开关;如果当前 AI 配置不支持联网,说明无法使用联网模式。\n"
|
||||
"- 不要编造已经执行了某个本地操作;只有系统明确完成后,才能说“已完成”。"))
|
||||
"- 不要声称已经访问本地文件、启动应用或执行系统操作,也不要编造任何未发生的操作结果。"))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -139,52 +132,6 @@ void ConversationManager::appendExternalExchange(const QString &userMessage, con
|
||||
appendExchange(user, assistant);
|
||||
}
|
||||
|
||||
void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback)
|
||||
{
|
||||
const QString content = message.trimmed();
|
||||
if (content.isEmpty())
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
callback({false, {}, QStringLiteral("Message is empty."), 0});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_provider)
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
callback({false, {}, QStringLiteral("AI provider is not ready."), 0});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBusy())
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
callback({false, {}, QStringLiteral("AI request is already running."), 0});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const 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 {
|
||||
if (response.success)
|
||||
{
|
||||
ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model};
|
||||
appendExchange(userMessage, assistantMessage);
|
||||
}
|
||||
|
||||
if (callback)
|
||||
{
|
||||
callback(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback)
|
||||
{
|
||||
const QString content = message.trimmed();
|
||||
|
||||
@@ -27,7 +27,6 @@ public:
|
||||
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 sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
|
||||
void cancel();
|
||||
void clear();
|
||||
|
||||
@@ -10,10 +10,6 @@ QString userIntentTypeName(UserIntentType type)
|
||||
return QStringLiteral("Reminder");
|
||||
case UserIntentType::Weather:
|
||||
return QStringLiteral("Weather");
|
||||
case UserIntentType::FileOperation:
|
||||
return QStringLiteral("FileOperation");
|
||||
case UserIntentType::LaunchApp:
|
||||
return QStringLiteral("LaunchApp");
|
||||
}
|
||||
|
||||
return QStringLiteral("Unknown");
|
||||
@@ -22,49 +18,15 @@ QString userIntentTypeName(UserIntentType type)
|
||||
CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
|
||||
{
|
||||
const UserIntent intent = m_intentRouter.route(text);
|
||||
if (intent.type == UserIntentType::Chat)
|
||||
switch (intent.type)
|
||||
{
|
||||
case UserIntentType::Chat:
|
||||
return {CommandDispatchAction::Chat, intent, intent.text};
|
||||
}
|
||||
|
||||
if (intent.type == UserIntentType::Reminder)
|
||||
{
|
||||
case UserIntentType::Reminder:
|
||||
return {CommandDispatchAction::Reminder, intent, intent.text};
|
||||
}
|
||||
|
||||
if (intent.type == UserIntentType::Weather)
|
||||
{
|
||||
case 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)};
|
||||
}
|
||||
|
||||
QString CommandDispatcher::unsupportedToolMessage(UserIntentType type) const
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserIntentType::Reminder:
|
||||
return QStringLiteral("提醒请求暂时无法处理,请稍后再试。");
|
||||
case UserIntentType::Weather:
|
||||
return QStringLiteral("天气查询请求暂时无法处理,请稍后再试。");
|
||||
case UserIntentType::FileOperation:
|
||||
return QStringLiteral("本地文件操作请求暂时无法处理,请稍后再试。");
|
||||
case UserIntentType::LaunchApp:
|
||||
return QStringLiteral("应用启动请求暂时无法处理,请稍后再试。");
|
||||
case UserIntentType::Chat:
|
||||
return QString();
|
||||
}
|
||||
|
||||
return QStringLiteral("该请求暂时无法处理,请稍后再试。");
|
||||
return {CommandDispatchAction::Chat, intent, intent.text};
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ enum class CommandDispatchAction
|
||||
Chat,
|
||||
Reminder,
|
||||
Weather,
|
||||
FileOperation,
|
||||
LaunchApp,
|
||||
UnsupportedTool,
|
||||
};
|
||||
|
||||
struct CommandDispatchResult
|
||||
@@ -27,7 +24,5 @@ public:
|
||||
CommandDispatchResult dispatch(const QString &text) const;
|
||||
|
||||
private:
|
||||
QString unsupportedToolMessage(UserIntentType type) const;
|
||||
|
||||
IntentRouter m_intentRouter;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "IntentRouter.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -14,7 +13,6 @@ bool containsAny(const QString &text, const QStringList &keywords)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -30,29 +28,6 @@ bool isReminderIntent(const QString &text)
|
||||
return containsAny(text, keywords);
|
||||
}
|
||||
|
||||
bool isFileOperationIntent(const QString &text)
|
||||
{
|
||||
static const QStringList keywords = {
|
||||
QStringLiteral("文件"),
|
||||
QStringLiteral("文件夹"),
|
||||
QStringLiteral("目录"),
|
||||
QStringLiteral("保存到"),
|
||||
QStringLiteral("截图"),
|
||||
QStringLiteral("打包"),
|
||||
QStringLiteral("压缩"),
|
||||
QStringLiteral("复制"),
|
||||
QStringLiteral("备份"),
|
||||
QStringLiteral("重命名"),
|
||||
QStringLiteral("删除"),
|
||||
QStringLiteral("移动"),
|
||||
QStringLiteral("读取"),
|
||||
QStringLiteral(".txt"),
|
||||
QStringLiteral(".md"),
|
||||
QStringLiteral("日志"),
|
||||
};
|
||||
return containsAny(text, keywords);
|
||||
}
|
||||
|
||||
bool isWeatherIntent(const QString &text)
|
||||
{
|
||||
static const QStringList keywords = {
|
||||
@@ -75,62 +50,18 @@ bool isWeatherIntent(const QString &text)
|
||||
};
|
||||
return containsAny(text, keywords);
|
||||
}
|
||||
|
||||
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 = {
|
||||
QStringLiteral("打开"),
|
||||
QStringLiteral("启动"),
|
||||
QStringLiteral("运行"),
|
||||
QStringLiteral("唤起"),
|
||||
};
|
||||
return containsAny(text, keywords);
|
||||
}
|
||||
}
|
||||
|
||||
UserIntent IntentRouter::route(const QString &text) const
|
||||
{
|
||||
const QString trimmedText = text.trimmed();
|
||||
|
||||
if (isReminderIntent(trimmedText))
|
||||
{
|
||||
return {UserIntentType::Reminder, trimmedText};
|
||||
}
|
||||
|
||||
if (isFileOperationIntent(trimmedText))
|
||||
{
|
||||
return {UserIntentType::FileOperation, trimmedText};
|
||||
}
|
||||
|
||||
if (isWeatherIntent(trimmedText))
|
||||
{
|
||||
return {UserIntentType::Weather, trimmedText};
|
||||
}
|
||||
|
||||
if (isLaunchAppIntent(trimmedText))
|
||||
{
|
||||
return {UserIntentType::LaunchApp, trimmedText};
|
||||
}
|
||||
|
||||
return {UserIntentType::Chat, trimmedText};
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ enum class UserIntentType
|
||||
Chat,
|
||||
Reminder,
|
||||
Weather,
|
||||
FileOperation,
|
||||
LaunchApp,
|
||||
};
|
||||
|
||||
struct UserIntent
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QJsonValue>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -535,18 +536,6 @@ QVector<CharacterPackageInfo> CharacterPackageRepository::availablePackages()
|
||||
return packages;
|
||||
}
|
||||
|
||||
QStringList CharacterPackageRepository::availablePackageIds()
|
||||
{
|
||||
QStringList packageIds;
|
||||
const QVector<CharacterPackageInfo> packages = availablePackages();
|
||||
for (const CharacterPackageInfo &package : packages)
|
||||
{
|
||||
packageIds.append(package.id);
|
||||
}
|
||||
|
||||
return packageIds;
|
||||
}
|
||||
|
||||
CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath)
|
||||
{
|
||||
CharacterPackageValidationResult result;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
struct CharacterPackageInfo
|
||||
@@ -40,7 +39,6 @@ public:
|
||||
static bool hasBuiltInPackage(const QString &characterId);
|
||||
static bool hasUserPackage(const QString &characterId);
|
||||
static QVector<CharacterPackageInfo> availablePackages();
|
||||
static QStringList availablePackageIds();
|
||||
static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath);
|
||||
static bool validatePackageDirectory(
|
||||
const QString &directoryPath,
|
||||
|
||||
@@ -79,11 +79,6 @@ bool FrameAnimator::isPlaying() const
|
||||
return m_timer.isActive() && m_clip != nullptr;
|
||||
}
|
||||
|
||||
bool FrameAnimator::isPaused() const
|
||||
{
|
||||
return m_paused && m_clip != nullptr;
|
||||
}
|
||||
|
||||
QString FrameAnimator::currentStateName() const
|
||||
{
|
||||
if (m_clip == nullptr)
|
||||
|
||||
@@ -22,7 +22,6 @@ public:
|
||||
void resume();
|
||||
void stop();
|
||||
bool isPlaying() const;
|
||||
bool isPaused() const;
|
||||
QString currentStateName() const;
|
||||
|
||||
private:
|
||||
|
||||
@@ -424,21 +424,6 @@ bool ConfigManager::saveAIConfigStore(const AIConfigStore &store) const
|
||||
return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
|
||||
}
|
||||
|
||||
bool ConfigManager::saveAIConfig(const AIConfig &config) const
|
||||
{
|
||||
AIConfig normalizedConfig = config;
|
||||
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
|
||||
if (isRemovedProviderName(normalizedConfig.provider))
|
||||
{
|
||||
normalizedConfig = defaultAIConfigForProvider(QStringLiteral("custom"));
|
||||
}
|
||||
|
||||
AIConfigStore store = loadAIConfigStore();
|
||||
store.activeProvider = normalizedConfig.provider;
|
||||
store.providers.insert(normalizedConfig.provider, normalizedConfig);
|
||||
return saveAIConfigStore(store);
|
||||
}
|
||||
|
||||
QString ConfigManager::appConfigPath() const
|
||||
{
|
||||
return QDir(configDirectoryPath()).filePath(AppConfigFileName);
|
||||
|
||||
@@ -15,7 +15,6 @@ public:
|
||||
AIConfig loadAIConfig() const;
|
||||
bool saveAppConfig(const AppConfig &config) const;
|
||||
bool saveAIConfigStore(const AIConfigStore &store) const;
|
||||
bool saveAIConfig(const AIConfig &config) const;
|
||||
QString appConfigPath() const;
|
||||
QString aiConfigPath() const;
|
||||
QString conversationHistoryPath() const;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,212 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,302 +0,0 @@
|
||||
#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));
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
+1
-43
@@ -1,11 +1,11 @@
|
||||
#include "ChatBubble.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QEvent>
|
||||
#include <QFrame>
|
||||
#include <QRect>
|
||||
#include <QScrollBar>
|
||||
#include <QSize>
|
||||
#include <QTextEdit>
|
||||
#include <QTextDocument>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
@@ -48,7 +48,6 @@ ChatBubble::ChatBubble(QWidget *parent)
|
||||
m_textEdit->installEventFilter(this);
|
||||
m_textEdit->viewport()->installEventFilter(this);
|
||||
m_textEdit->verticalScrollBar()->installEventFilter(this);
|
||||
qApp->installEventFilter(this);
|
||||
m_textEdit->setStyleSheet(QStringLiteral(
|
||||
"QTextEdit {"
|
||||
"background: rgba(255, 255, 255, 232);"
|
||||
@@ -80,17 +79,8 @@ ChatBubble::ChatBubble(QWidget *parent)
|
||||
});
|
||||
}
|
||||
|
||||
ChatBubble::~ChatBubble()
|
||||
{
|
||||
if (qApp != nullptr)
|
||||
{
|
||||
qApp->removeEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom)
|
||||
{
|
||||
m_dismissOnExternalInteraction = false;
|
||||
m_anchorPosition = anchorPosition;
|
||||
m_autoHideDurationMs = durationMs;
|
||||
const QString trimmed = message.trimmed();
|
||||
@@ -129,11 +119,6 @@ void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
void ChatBubble::setDismissOnExternalInteraction(bool enabled)
|
||||
{
|
||||
m_dismissOnExternalInteraction = enabled;
|
||||
}
|
||||
|
||||
void ChatBubble::resetAutoHideTimer()
|
||||
{
|
||||
if (isVisible() && m_autoHideDurationMs > 0)
|
||||
@@ -150,18 +135,6 @@ void ChatBubble::hideBubble()
|
||||
|
||||
bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched == qApp && m_dismissOnExternalInteraction && event->type() == QEvent::ApplicationDeactivate)
|
||||
{
|
||||
hideBubble();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_dismissOnExternalInteraction && event->type() == QEvent::MouseButtonPress && !isOwnObject(watched))
|
||||
{
|
||||
hideBubble();
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((watched == m_textEdit || watched == m_textEdit->viewport() || watched == m_textEdit->verticalScrollBar())
|
||||
&& isUserInteractionEvent(event))
|
||||
{
|
||||
@@ -171,21 +144,6 @@ bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
bool ChatBubble::isOwnObject(QObject *object) const
|
||||
{
|
||||
while (object != nullptr)
|
||||
{
|
||||
if (object == this || object == m_textEdit || object == m_textEdit->viewport() || object == m_textEdit->verticalScrollBar())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
object = object->parent();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatBubble::isUserInteractionEvent(QEvent *event) const
|
||||
{
|
||||
switch (event->type())
|
||||
|
||||
+1
-5
@@ -1,16 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <QTextEdit>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
class QEvent;
|
||||
class QTextEdit;
|
||||
|
||||
class ChatBubble : public QWidget
|
||||
{
|
||||
public:
|
||||
explicit ChatBubble(QWidget *parent = nullptr);
|
||||
~ChatBubble() override;
|
||||
|
||||
void showMessage(
|
||||
const QString &message,
|
||||
@@ -18,7 +17,6 @@ public:
|
||||
int durationMs = 10000,
|
||||
bool scrollToBottom = false);
|
||||
void updateAnchorPosition(const QPoint &anchorPosition);
|
||||
void setDismissOnExternalInteraction(bool enabled);
|
||||
void resetAutoHideTimer();
|
||||
void hideBubble();
|
||||
|
||||
@@ -26,7 +24,6 @@ protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
bool isOwnObject(QObject *object) const;
|
||||
bool isUserInteractionEvent(QEvent *event) const;
|
||||
QSize preferredBubbleSize(const QString &message) const;
|
||||
void updatePosition();
|
||||
@@ -35,5 +32,4 @@ private:
|
||||
QTimer m_hideTimer;
|
||||
QPoint m_anchorPosition;
|
||||
int m_autoHideDurationMs = 0;
|
||||
bool m_dismissOnExternalInteraction = false;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPushButton>
|
||||
#include <QSignalBlocker>
|
||||
#include <QTextEdit>
|
||||
|
||||
#include <utility>
|
||||
@@ -106,6 +107,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
||||
connect(m_textEdit, &QTextEdit::textChanged, this, [this]() {
|
||||
updateCounter();
|
||||
});
|
||||
connect(m_webToggleCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
if (m_webToggleChangedCallback)
|
||||
{
|
||||
m_webToggleChangedCallback(checked);
|
||||
}
|
||||
});
|
||||
|
||||
auto *panel = new QFrame(this);
|
||||
panel->setObjectName(QStringLiteral("InputPanel"));
|
||||
@@ -143,6 +150,7 @@ void ChatInputDialog::setWebEnabled(bool enabled)
|
||||
{
|
||||
if (m_webToggleCheckBox)
|
||||
{
|
||||
const QSignalBlocker blocker(m_webToggleCheckBox);
|
||||
m_webToggleCheckBox->setChecked(enabled);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +162,7 @@ void ChatInputDialog::setWebToggleAvailable(bool available, const QString &toolT
|
||||
return;
|
||||
}
|
||||
|
||||
const QSignalBlocker blocker(m_webToggleCheckBox);
|
||||
m_webToggleCheckBox->setEnabled(available);
|
||||
if (!available)
|
||||
{
|
||||
@@ -169,6 +178,11 @@ void ChatInputDialog::setSubmitCallback(SubmitCallback callback)
|
||||
m_submitCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void ChatInputDialog::setWebToggleChangedCallback(WebToggleChangedCallback callback)
|
||||
{
|
||||
m_webToggleChangedCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void ChatInputDialog::showAt(const QPoint &anchorPosition)
|
||||
{
|
||||
move(anchorPosition.x() - width() / 2, anchorPosition.y() - height() / 2);
|
||||
|
||||
@@ -17,6 +17,7 @@ class ChatInputDialog : public QDialog
|
||||
{
|
||||
public:
|
||||
using SubmitCallback = std::function<bool(const QString &, bool)>;
|
||||
using WebToggleChangedCallback = std::function<void(bool)>;
|
||||
|
||||
explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr);
|
||||
|
||||
@@ -25,6 +26,7 @@ public:
|
||||
void setWebEnabled(bool enabled);
|
||||
void setWebToggleAvailable(bool available, const QString &toolTip = QString());
|
||||
void setSubmitCallback(SubmitCallback callback);
|
||||
void setWebToggleChangedCallback(WebToggleChangedCallback callback);
|
||||
void showAt(const QPoint &anchorPosition);
|
||||
|
||||
protected:
|
||||
@@ -48,6 +50,7 @@ private:
|
||||
QCheckBox *m_webToggleCheckBox = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
SubmitCallback m_submitCallback;
|
||||
WebToggleChangedCallback m_webToggleChangedCallback;
|
||||
QPoint m_dragStartPosition;
|
||||
QPoint m_dragWindowPosition;
|
||||
int m_maxLength = 0;
|
||||
|
||||
+9
-344
@@ -7,9 +7,6 @@
|
||||
#include "../character/CharacterPackageLoader.h"
|
||||
#include "../character/CharacterPackageRepository.h"
|
||||
#include "../config/ConfigManager.h"
|
||||
#include "../fileops/FileOperationManager.h"
|
||||
#include "../launcher/AppLaunchManager.h"
|
||||
#include "../launcher/AppLaunchStore.h"
|
||||
#include "../notification/NotificationDispatcher.h"
|
||||
#include "../reminder/ReminderCommandHandler.h"
|
||||
#include "../reminder/ReminderManager.h"
|
||||
@@ -33,17 +30,11 @@
|
||||
#include <QContextMenuEvent>
|
||||
#include <QCursor>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QGuiApplication>
|
||||
#include <QHideEvent>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QList>
|
||||
#include <QLineEdit>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QMouseEvent>
|
||||
@@ -51,7 +42,6 @@
|
||||
#include <QPointF>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QCheckBox>
|
||||
#include <QRandomGenerator>
|
||||
#include <QScreen>
|
||||
#include <QSet>
|
||||
@@ -201,8 +191,6 @@ PetWindow::PetWindow(QWidget *parent)
|
||||
, m_chatInputDialog(std::make_unique<ChatInputDialog>(MaxUserMessageLength, this))
|
||||
, m_conversationManager(std::make_unique<ConversationManager>())
|
||||
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
|
||||
, m_fileOperationManager(std::make_unique<FileOperationManager>())
|
||||
, m_appLaunchManager(std::make_unique<AppLaunchManager>())
|
||||
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
|
||||
, m_reminderManager(std::make_unique<ReminderManager>())
|
||||
, m_reminderSoundPlayer(std::make_unique<ReminderSoundPlayer>())
|
||||
@@ -255,7 +243,12 @@ PetWindow::PetWindow(QWidget *parent)
|
||||
m_chatInputDialog->setSubmitCallback([window](const QString &message, bool webEnabled) {
|
||||
return !window.isNull() && window->submitChatMessage(message, webEnabled);
|
||||
});
|
||||
|
||||
m_chatInputDialog->setWebToggleChangedCallback([window](bool webEnabled) {
|
||||
if (!window.isNull())
|
||||
{
|
||||
window->saveWebTogglePreference(webEnabled);
|
||||
}
|
||||
});
|
||||
m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) {
|
||||
if (!window.isNull())
|
||||
{
|
||||
@@ -385,7 +378,6 @@ void PetWindow::openSettingsDialog()
|
||||
ConfigManager configManager;
|
||||
WeatherStore weatherStore;
|
||||
WebStore webStore;
|
||||
AppLaunchStore appLaunchStore;
|
||||
QString weatherConfigError;
|
||||
const WeatherConfig weatherConfig = weatherStore.load(&weatherConfigError);
|
||||
if (!weatherConfigError.isEmpty())
|
||||
@@ -398,19 +390,11 @@ void PetWindow::openSettingsDialog()
|
||||
{
|
||||
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(
|
||||
configManager.loadAIConfigStore(),
|
||||
currentAppConfig(),
|
||||
weatherConfig,
|
||||
webConfig,
|
||||
appLaunchConfig,
|
||||
m_conversationManager ? m_conversationManager->history() : QVector<ChatMessage>(),
|
||||
m_reminderManager ? m_reminderManager->allReminders() : QVector<ReminderItem>(),
|
||||
[this]() {
|
||||
@@ -513,11 +497,6 @@ void PetWindow::openSettingsDialog()
|
||||
}
|
||||
refreshChatInputWebToggle();
|
||||
|
||||
QString saveLauncherConfigError;
|
||||
if (!appLaunchStore.save(dialog.appLaunchConfig(), &saveLauncherConfigError))
|
||||
{
|
||||
Logger::warning(QStringLiteral("Failed to save launcher config from settings dialog: ") + saveLauncherConfigError);
|
||||
}
|
||||
}
|
||||
|
||||
void PetWindow::activateFromExternalInstance()
|
||||
@@ -669,23 +648,6 @@ bool PetWindow::submitChatMessage(const QString &message, bool webEnabled)
|
||||
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)
|
||||
{
|
||||
playState(QStringLiteral("talk"), false);
|
||||
showBubbleMessage(result.message);
|
||||
return true;
|
||||
}
|
||||
|
||||
saveWebTogglePreference(webEnabled);
|
||||
return webEnabled ? submitWebChatMessage(result.message) : submitAiChatMessage(result.message);
|
||||
}
|
||||
@@ -707,6 +669,7 @@ bool PetWindow::handleReminderChatMessage(const QString &message)
|
||||
return result.success;
|
||||
}
|
||||
|
||||
|
||||
bool PetWindow::handleWeatherChatMessage(const QString &message)
|
||||
{
|
||||
if (!m_weatherManager)
|
||||
@@ -765,305 +728,6 @@ bool PetWindow::handleWeatherChatMessage(const QString &message)
|
||||
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)
|
||||
@@ -1648,7 +1312,8 @@ void PetWindow::cancelActiveAIRequest()
|
||||
|
||||
bool PetWindow::hasActiveAIRequest() const
|
||||
{
|
||||
return (m_conversationManager && m_conversationManager->isBusy()) || hasActiveWebRequest();
|
||||
return (m_conversationManager && m_conversationManager->isBusy())
|
||||
|| hasActiveWebRequest();
|
||||
}
|
||||
|
||||
bool PetWindow::isManualStateSwitchLocked() const
|
||||
|
||||
@@ -30,8 +30,6 @@ class ChatHistoryPanel;
|
||||
class ChatInputDialog;
|
||||
class ConversationManager;
|
||||
class ConversationStore;
|
||||
class FileOperationManager;
|
||||
class AppLaunchManager;
|
||||
class NotificationDispatcher;
|
||||
class PetView;
|
||||
class ReminderManager;
|
||||
@@ -76,8 +74,6 @@ private:
|
||||
bool submitWebChatMessage(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();
|
||||
@@ -142,8 +138,6 @@ private:
|
||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||
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<ReminderManager> m_reminderManager;
|
||||
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include "../ai/LLMTypes.h"
|
||||
#include "../character/CharacterPackageRepository.h"
|
||||
#include "../config/SecretStore.h"
|
||||
#include "../launcher/AppLaunchManager.h"
|
||||
#include "../reminder/ReminderSoundRepository.h"
|
||||
#include "../web/WebCapabilityDetector.h"
|
||||
#include "../web/WebChatManager.h"
|
||||
@@ -21,7 +20,6 @@
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QList>
|
||||
@@ -225,44 +223,6 @@ QString webCapabilityDisplayText(const WebCapability &capability)
|
||||
return QStringLiteral("当前联网能力:%1。%2").arg(state, capability.userMessage);
|
||||
}
|
||||
|
||||
QString launchAppDisplayText(const RegisteredApp &app)
|
||||
{
|
||||
QStringList lines;
|
||||
lines.append(app.displayName);
|
||||
if (!app.aliases.isEmpty())
|
||||
{
|
||||
lines.append(QStringLiteral("别名:%1").arg(app.aliases.join(QStringLiteral(" / "))));
|
||||
}
|
||||
if (!app.executablePath.trimmed().isEmpty())
|
||||
{
|
||||
lines.append(QStringLiteral("路径:%1").arg(app.executablePath));
|
||||
}
|
||||
else if (!app.shortcutPath.trimmed().isEmpty())
|
||||
{
|
||||
lines.append(QStringLiteral("快捷方式:%1").arg(app.shortcutPath));
|
||||
}
|
||||
return lines.join(QLatin1Char('\n'));
|
||||
}
|
||||
|
||||
QStringList aliasesFromUserText(const QString &text)
|
||||
{
|
||||
QString normalized = text;
|
||||
normalized.replace(QStringLiteral(","), QStringLiteral(","));
|
||||
normalized.replace(QStringLiteral(";"), QStringLiteral(","));
|
||||
normalized.replace(QStringLiteral(";"), QStringLiteral(","));
|
||||
|
||||
QStringList aliases;
|
||||
for (const QString &part : normalized.split(QLatin1Char(','), Qt::SkipEmptyParts))
|
||||
{
|
||||
const QString alias = part.trimmed();
|
||||
if (!alias.isEmpty() && !aliases.contains(alias, Qt::CaseInsensitive))
|
||||
{
|
||||
aliases.append(alias);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
QString chatRoleDisplayText(const QString &role)
|
||||
{
|
||||
if (role == QStringLiteral("user"))
|
||||
@@ -514,7 +474,6 @@ SettingsDialog::SettingsDialog(
|
||||
const AppConfig &appConfig,
|
||||
const WeatherConfig &weatherConfig,
|
||||
const WebConfig &webConfig,
|
||||
const AppLaunchConfig &appLaunchConfig,
|
||||
const QVector<ChatMessage> &conversationHistory,
|
||||
const QVector<ReminderItem> &reminders,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
@@ -567,14 +526,6 @@ SettingsDialog::SettingsDialog(
|
||||
, m_webShowCitationsCheckBox(new QCheckBox(QStringLiteral("显示来源引用"), this))
|
||||
, m_testWebButton(new QPushButton(QStringLiteral("测试联网模式"), this))
|
||||
, m_webStatusLabel(new QLabel(this))
|
||||
, m_launchEnabledCheckBox(new QCheckBox(QStringLiteral("启用应用启动"), this))
|
||||
, m_launchManualSelectionCheckBox(new QCheckBox(QStringLiteral("未知应用允许手选 exe"), this))
|
||||
, m_launchAppListWidget(new QListWidget(this))
|
||||
, m_addLaunchAppButton(new QPushButton(QStringLiteral("添加应用"), this))
|
||||
, m_editLaunchAppButton(new QPushButton(QStringLiteral("编辑选中"), this))
|
||||
, m_deleteLaunchAppButton(new QPushButton(QStringLiteral("删除选中"), this))
|
||||
, m_testLaunchAppButton(new QPushButton(QStringLiteral("测试启动"), this))
|
||||
, m_launchStatusLabel(new QLabel(this))
|
||||
, m_characterComboBox(new QComboBox(this))
|
||||
, m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this))
|
||||
, m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this))
|
||||
@@ -598,7 +549,6 @@ SettingsDialog::SettingsDialog(
|
||||
, m_appConfig(appConfig)
|
||||
, m_weatherConfig(weatherConfig)
|
||||
, m_webConfig(webConfig)
|
||||
, m_appLaunchConfig(appLaunchConfig)
|
||||
, m_conversationHistory(conversationHistory)
|
||||
, m_reminders(reminders)
|
||||
, m_aiTestBlocked(std::move(aiTestBlocked))
|
||||
@@ -607,7 +557,6 @@ SettingsDialog::SettingsDialog(
|
||||
, m_updateReminder(std::move(updateReminderCallback))
|
||||
, m_clearFinishedReminders(std::move(clearFinishedRemindersCallback))
|
||||
, m_playReminderSound(std::move(playReminderSoundCallback))
|
||||
, m_launchTestManager(std::make_unique<AppLaunchManager>())
|
||||
, m_webTestManager(std::make_unique<WebChatManager>())
|
||||
, m_weatherTestManager(std::make_unique<WeatherManager>())
|
||||
{
|
||||
@@ -930,50 +879,6 @@ SettingsDialog::SettingsDialog(
|
||||
auto *webPage = new QWidget(this);
|
||||
webPage->setLayout(webPageLayout);
|
||||
|
||||
m_launchStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
m_launchStatusLabel->setWordWrap(true);
|
||||
m_launchStatusLabel->setText(QStringLiteral("应用启动只支持 .exe 和开始菜单快捷方式;不执行脚本、命令、安装包,也不会拼接聊天文本参数。"));
|
||||
m_launchEnabledCheckBox->setChecked(m_appLaunchConfig.enabled);
|
||||
m_launchManualSelectionCheckBox->setChecked(m_appLaunchConfig.allowManualExeSelection);
|
||||
m_launchAppListWidget->setObjectName(QStringLiteral("LaunchAppList"));
|
||||
m_launchAppListWidget->setFrameShape(QFrame::NoFrame);
|
||||
m_launchAppListWidget->setMinimumHeight(150);
|
||||
m_launchAppListWidget->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
reloadLaunchAppList();
|
||||
|
||||
auto *launchTitleLabel = new QLabel(QStringLiteral("应用启动"), this);
|
||||
launchTitleLabel->setObjectName(QStringLiteral("PageTitle"));
|
||||
auto *launchHintLabel = new QLabel(QStringLiteral("通过聊天输入“打开 Codex / 启动酷狗音乐”等触发。未知应用可手选 exe,启动前始终二次确认。"), this);
|
||||
launchHintLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
launchHintLabel->setWordWrap(true);
|
||||
|
||||
auto *launchFormLayout = new QFormLayout();
|
||||
launchFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
launchFormLayout->setLabelAlignment(Qt::AlignRight);
|
||||
launchFormLayout->setHorizontalSpacing(18);
|
||||
launchFormLayout->setVerticalSpacing(12);
|
||||
launchFormLayout->addRow(QString(), m_launchEnabledCheckBox);
|
||||
launchFormLayout->addRow(QString(), m_launchManualSelectionCheckBox);
|
||||
|
||||
auto *launchActionLayout = new QHBoxLayout();
|
||||
launchActionLayout->addWidget(m_addLaunchAppButton);
|
||||
launchActionLayout->addWidget(m_editLaunchAppButton);
|
||||
launchActionLayout->addWidget(m_deleteLaunchAppButton);
|
||||
launchActionLayout->addWidget(m_testLaunchAppButton);
|
||||
launchActionLayout->addWidget(m_launchStatusLabel, 1);
|
||||
|
||||
auto *launchPageLayout = new QVBoxLayout();
|
||||
launchPageLayout->setContentsMargins(24, 24, 24, 24);
|
||||
launchPageLayout->setSpacing(16);
|
||||
launchPageLayout->addWidget(launchTitleLabel);
|
||||
launchPageLayout->addWidget(launchHintLabel);
|
||||
launchPageLayout->addLayout(launchFormLayout);
|
||||
launchPageLayout->addWidget(m_launchAppListWidget);
|
||||
launchPageLayout->addLayout(launchActionLayout);
|
||||
launchPageLayout->addStretch();
|
||||
|
||||
auto *launchPage = new QWidget(this);
|
||||
launchPage->setLayout(launchPageLayout);
|
||||
|
||||
m_reminderStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
m_reminderStatusLabel->setWordWrap(true);
|
||||
@@ -1090,7 +995,6 @@ SettingsDialog::SettingsDialog(
|
||||
navigationList->addItem(QStringLiteral("聊天"));
|
||||
navigationList->addItem(QStringLiteral("天气"));
|
||||
navigationList->addItem(QStringLiteral("联网"));
|
||||
navigationList->addItem(QStringLiteral("应用启动"));
|
||||
navigationList->addItem(QStringLiteral("提醒"));
|
||||
navigationList->addItem(QStringLiteral("应用"));
|
||||
navigationList->addItem(QStringLiteral("角色"));
|
||||
@@ -1101,7 +1005,6 @@ SettingsDialog::SettingsDialog(
|
||||
pageStack->addWidget(chatPage);
|
||||
pageStack->addWidget(weatherPage);
|
||||
pageStack->addWidget(webPage);
|
||||
pageStack->addWidget(launchPage);
|
||||
pageStack->addWidget(reminderPage);
|
||||
pageStack->addWidget(appPage);
|
||||
pageStack->addWidget(characterPage);
|
||||
@@ -1153,22 +1056,12 @@ SettingsDialog::SettingsDialog(
|
||||
" background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;"
|
||||
" padding: 0px;"
|
||||
"}"
|
||||
"QListWidget#LaunchAppList {"
|
||||
" background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;"
|
||||
" padding: 0px;"
|
||||
"}"
|
||||
"QListWidget#ReminderList::item {"
|
||||
" min-height: 28px; padding: 5px 8px; color: #344054;"
|
||||
"}"
|
||||
"QListWidget#LaunchAppList::item {"
|
||||
" min-height: 34px; padding: 6px 8px; color: #344054;"
|
||||
"}"
|
||||
"QListWidget#ReminderList::item:selected {"
|
||||
" background: #eaf3ff; color: #175cd3;"
|
||||
"}"
|
||||
"QListWidget#LaunchAppList::item:selected {"
|
||||
" background: #eaf3ff; color: #175cd3;"
|
||||
"}"
|
||||
"QStackedWidget#SettingsPages {"
|
||||
" background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;"
|
||||
"}"
|
||||
@@ -1226,21 +1119,6 @@ SettingsDialog::SettingsDialog(
|
||||
connect(m_testWebButton, &QPushButton::clicked, this, [this]() {
|
||||
testWebMode();
|
||||
});
|
||||
connect(m_launchAppListWidget, &QListWidget::currentItemChanged, this, [this]() {
|
||||
updateLaunchAppButtons();
|
||||
});
|
||||
connect(m_addLaunchAppButton, &QPushButton::clicked, this, [this]() {
|
||||
addLaunchApp();
|
||||
});
|
||||
connect(m_editLaunchAppButton, &QPushButton::clicked, this, [this]() {
|
||||
editSelectedLaunchApp();
|
||||
});
|
||||
connect(m_deleteLaunchAppButton, &QPushButton::clicked, this, [this]() {
|
||||
deleteSelectedLaunchApp();
|
||||
});
|
||||
connect(m_testLaunchAppButton, &QPushButton::clicked, this, [this]() {
|
||||
testSelectedLaunchApp();
|
||||
});
|
||||
connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||
importCharacterFolder();
|
||||
});
|
||||
@@ -1282,7 +1160,6 @@ SettingsDialog::SettingsDialog(
|
||||
});
|
||||
updateReminderActionButtons();
|
||||
updateReminderSoundButtons();
|
||||
updateLaunchAppButtons();
|
||||
refreshWebCapabilityStatus();
|
||||
}
|
||||
|
||||
@@ -1380,15 +1257,6 @@ WebConfig SettingsDialog::webConfig() const
|
||||
return config;
|
||||
}
|
||||
|
||||
AppLaunchConfig SettingsDialog::appLaunchConfig() const
|
||||
{
|
||||
AppLaunchConfig config = m_appLaunchConfig;
|
||||
config.enabled = m_launchEnabledCheckBox->isChecked();
|
||||
config.allowManualExeSelection = m_launchManualSelectionCheckBox->isChecked();
|
||||
config.alwaysConfirmBeforeLaunch = true;
|
||||
return config;
|
||||
}
|
||||
|
||||
void SettingsDialog::testWeatherDefaultCity()
|
||||
{
|
||||
const QString cityName = m_weatherDefaultCityEdit->text().trimmed();
|
||||
@@ -1508,233 +1376,6 @@ void SettingsDialog::testWebMode()
|
||||
});
|
||||
}
|
||||
|
||||
void SettingsDialog::reloadLaunchAppList()
|
||||
{
|
||||
m_launchAppListWidget->clear();
|
||||
for (const RegisteredApp &app : m_appLaunchConfig.apps)
|
||||
{
|
||||
auto *item = new QListWidgetItem(launchAppDisplayText(app));
|
||||
item->setData(Qt::UserRole, app.id);
|
||||
item->setToolTip(launchAppDisplayText(app));
|
||||
m_launchAppListWidget->addItem(item);
|
||||
}
|
||||
updateLaunchAppButtons();
|
||||
}
|
||||
|
||||
void SettingsDialog::updateLaunchAppButtons()
|
||||
{
|
||||
const bool hasSelection = !selectedLaunchAppId().isEmpty();
|
||||
m_editLaunchAppButton->setEnabled(hasSelection);
|
||||
m_deleteLaunchAppButton->setEnabled(hasSelection);
|
||||
m_testLaunchAppButton->setEnabled(hasSelection);
|
||||
}
|
||||
|
||||
QString SettingsDialog::selectedLaunchAppId() const
|
||||
{
|
||||
const QListWidgetItem *item = m_launchAppListWidget->currentItem();
|
||||
return item == nullptr ? QString() : item->data(Qt::UserRole).toString();
|
||||
}
|
||||
|
||||
void SettingsDialog::addLaunchApp()
|
||||
{
|
||||
const QString executablePath = QFileDialog::getOpenFileName(
|
||||
this,
|
||||
QStringLiteral("选择要登记的应用"),
|
||||
QString(),
|
||||
QStringLiteral("Applications (*.exe)"));
|
||||
if (executablePath.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const QFileInfo info(executablePath);
|
||||
if (info.suffix().compare(QStringLiteral("exe"), Qt::CaseInsensitive) != 0)
|
||||
{
|
||||
QMessageBox::warning(this, QStringLiteral("不能添加"), QStringLiteral("应用启动只允许登记 .exe。"));
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const QString displayName = QInputDialog::getText(
|
||||
this,
|
||||
QStringLiteral("应用名称"),
|
||||
QStringLiteral("显示名称"),
|
||||
QLineEdit::Normal,
|
||||
info.completeBaseName(),
|
||||
&ok).trimmed();
|
||||
if (!ok || displayName.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const QString aliasText = QInputDialog::getText(
|
||||
this,
|
||||
QStringLiteral("应用别名"),
|
||||
QStringLiteral("别名,用逗号分隔"),
|
||||
QLineEdit::Normal,
|
||||
displayName,
|
||||
&ok).trimmed();
|
||||
if (!ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLaunchPlan plan;
|
||||
plan.requestedName = displayName;
|
||||
plan.displayName = displayName;
|
||||
plan.executablePath = info.absoluteFilePath();
|
||||
plan.workingDirectory = info.absolutePath();
|
||||
plan.matchSource = QStringLiteral("设置页登记");
|
||||
RegisteredApp app = m_launchTestManager->registeredAppFromPlan(plan, aliasesFromUserText(aliasText));
|
||||
app.userDefined = true;
|
||||
const QString baseId = app.id;
|
||||
int suffix = 2;
|
||||
const auto idExists = [this](const QString &id) {
|
||||
for (const RegisteredApp &existingApp : m_appLaunchConfig.apps)
|
||||
{
|
||||
if (existingApp.id == id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
while (idExists(app.id))
|
||||
{
|
||||
app.id = baseId + QStringLiteral("_") + QString::number(suffix++);
|
||||
}
|
||||
m_appLaunchConfig.apps.append(app);
|
||||
reloadLaunchAppList();
|
||||
m_launchStatusLabel->setText(QStringLiteral("已添加应用:%1。点击保存后生效。").arg(displayName));
|
||||
}
|
||||
|
||||
void SettingsDialog::editSelectedLaunchApp()
|
||||
{
|
||||
const QString appId = selectedLaunchAppId();
|
||||
if (appId.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (RegisteredApp &app : m_appLaunchConfig.apps)
|
||||
{
|
||||
if (app.id != appId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const QString displayName = QInputDialog::getText(
|
||||
this,
|
||||
QStringLiteral("编辑应用"),
|
||||
QStringLiteral("显示名称"),
|
||||
QLineEdit::Normal,
|
||||
app.displayName,
|
||||
&ok).trimmed();
|
||||
if (!ok || displayName.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const QString aliasText = QInputDialog::getText(
|
||||
this,
|
||||
QStringLiteral("编辑别名"),
|
||||
QStringLiteral("别名,用逗号分隔"),
|
||||
QLineEdit::Normal,
|
||||
app.aliases.join(QStringLiteral(", ")),
|
||||
&ok).trimmed();
|
||||
if (!ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
app.displayName = displayName;
|
||||
app.aliases = aliasesFromUserText(aliasText);
|
||||
if (!app.aliases.contains(displayName, Qt::CaseInsensitive))
|
||||
{
|
||||
app.aliases.prepend(displayName);
|
||||
}
|
||||
reloadLaunchAppList();
|
||||
m_launchStatusLabel->setText(QStringLiteral("已更新应用:%1。点击保存后生效。").arg(displayName));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::deleteSelectedLaunchApp()
|
||||
{
|
||||
const QString appId = selectedLaunchAppId();
|
||||
if (appId.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int answer = QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("删除登记应用"),
|
||||
QStringLiteral("只会删除设置页中的应用登记,不会删除磁盘上的程序文件。是否继续?"),
|
||||
QMessageBox::Yes | QMessageBox::Cancel,
|
||||
QMessageBox::Cancel);
|
||||
if (answer != QMessageBox::Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < m_appLaunchConfig.apps.size(); ++index)
|
||||
{
|
||||
if (m_appLaunchConfig.apps.at(index).id == appId)
|
||||
{
|
||||
const QString name = m_appLaunchConfig.apps.at(index).displayName;
|
||||
m_appLaunchConfig.apps.removeAt(index);
|
||||
reloadLaunchAppList();
|
||||
m_launchStatusLabel->setText(QStringLiteral("已删除登记:%1。点击保存后生效。").arg(name));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::testSelectedLaunchApp()
|
||||
{
|
||||
const QString appId = selectedLaunchAppId();
|
||||
if (appId.isEmpty() || !m_launchTestManager)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (const RegisteredApp &app : m_appLaunchConfig.apps)
|
||||
{
|
||||
if (app.id != appId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AppLaunchPlan plan;
|
||||
plan.success = true;
|
||||
plan.requestedName = app.displayName;
|
||||
plan.displayName = app.displayName;
|
||||
plan.executablePath = app.executablePath;
|
||||
plan.shortcutPath = app.shortcutPath;
|
||||
plan.workingDirectory = app.workingDirectory;
|
||||
plan.matchSource = QStringLiteral("设置页测试");
|
||||
|
||||
const int answer = QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("测试启动应用"),
|
||||
QStringLiteral("即将启动:%1\n路径:%2\n\n是否继续?")
|
||||
.arg(plan.displayName, plan.executablePath.isEmpty() ? plan.shortcutPath : plan.executablePath),
|
||||
QMessageBox::Yes | QMessageBox::Cancel,
|
||||
QMessageBox::Cancel);
|
||||
if (answer != QMessageBox::Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const AppLaunchResult result = m_launchTestManager->executeLaunchPlan(plan);
|
||||
m_launchStatusLabel->setText(result.success
|
||||
? result.message
|
||||
: (result.errorMessage.isEmpty() ? QStringLiteral("测试启动失败。") : result.errorMessage));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::accept()
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include "../config/AIConfig.h"
|
||||
#include "../config/AppConfig.h"
|
||||
#include "../ai/LLMTypes.h"
|
||||
#include "../launcher/AppLaunchTypes.h"
|
||||
#include "../reminder/ReminderTypes.h"
|
||||
#include "../web/WebConfig.h"
|
||||
#include "../weather/WeatherConfig.h"
|
||||
@@ -23,7 +22,6 @@ class QListWidget;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
class LLMProvider;
|
||||
class AppLaunchManager;
|
||||
class WebChatManager;
|
||||
class WeatherManager;
|
||||
|
||||
@@ -35,7 +33,6 @@ public:
|
||||
const AppConfig &appConfig,
|
||||
const WeatherConfig &weatherConfig,
|
||||
const WebConfig &webConfig,
|
||||
const AppLaunchConfig &appLaunchConfig,
|
||||
const QVector<ChatMessage> &conversationHistory,
|
||||
const QVector<ReminderItem> &reminders,
|
||||
std::function<bool()> aiTestBlocked,
|
||||
@@ -51,7 +48,6 @@ public:
|
||||
AppConfig appConfig() const;
|
||||
WeatherConfig weatherConfig() const;
|
||||
WebConfig webConfig() const;
|
||||
AppLaunchConfig appLaunchConfig() const;
|
||||
|
||||
protected:
|
||||
void accept() override;
|
||||
@@ -90,13 +86,6 @@ private:
|
||||
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;
|
||||
QLineEdit *m_baseUrlEdit = nullptr;
|
||||
@@ -142,14 +131,6 @@ private:
|
||||
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;
|
||||
QPushButton *m_importCharacterButton = nullptr;
|
||||
QPushButton *m_deleteCharacterButton = nullptr;
|
||||
@@ -174,7 +155,6 @@ private:
|
||||
AppConfig m_appConfig;
|
||||
WeatherConfig m_weatherConfig;
|
||||
WebConfig m_webConfig;
|
||||
AppLaunchConfig m_appLaunchConfig;
|
||||
QVector<ChatMessage> m_conversationHistory;
|
||||
QVector<ReminderItem> m_reminders;
|
||||
QString m_currentProvider;
|
||||
@@ -185,7 +165,6 @@ private:
|
||||
std::function<bool(QString *)> m_clearFinishedReminders;
|
||||
std::function<void(const QString &, double)> m_playReminderSound;
|
||||
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;
|
||||
|
||||
@@ -27,17 +27,6 @@ QString sourceResourcesRootPath()
|
||||
}
|
||||
}
|
||||
|
||||
QString ResourcePaths::resourcesRootPath()
|
||||
{
|
||||
const QString appResources = appResourcesRootPath();
|
||||
if (QFileInfo::exists(appResources))
|
||||
{
|
||||
return appResources;
|
||||
}
|
||||
|
||||
return sourceResourcesRootPath();
|
||||
}
|
||||
|
||||
QString ResourcePaths::resourcePath(const QString &relativePath)
|
||||
{
|
||||
const QString cleanedRelativePath = cleanRelativePath(relativePath);
|
||||
@@ -64,8 +53,3 @@ QString ResourcePaths::appIconPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("icons/app_icon.ico"));
|
||||
}
|
||||
|
||||
QString ResourcePaths::appIconSourcePngPath()
|
||||
{
|
||||
return resourcePath(QStringLiteral("icons/app_icon_1024.png"));
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
class ResourcePaths
|
||||
{
|
||||
public:
|
||||
static QString resourcesRootPath();
|
||||
static QString resourcePath(const QString &relativePath);
|
||||
static QString charactersRootPath();
|
||||
static QString reminderSoundsRootPath();
|
||||
static QString appIconPath();
|
||||
static QString appIconSourcePngPath();
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ function Should-ExcludeDirectory([string]$RelativePath) {
|
||||
"dist",
|
||||
"release_packages",
|
||||
"docs",
|
||||
"internal",
|
||||
"reports",
|
||||
"config",
|
||||
"logs"
|
||||
@@ -134,6 +135,15 @@ if (Test-Path -LiteralPath $outputPath) {
|
||||
New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
|
||||
Copy-PublicTree -SourceDir $repoRoot -DestinationDir $outputPath -RelativeDir ""
|
||||
|
||||
foreach ($forbiddenRoot in @("docs", "internal", "reports")) {
|
||||
if (Test-Path -LiteralPath (Join-Path $outputPath $forbiddenRoot)) {
|
||||
throw "Public export isolation failed; forbidden directory was copied: $forbiddenRoot"
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath (Join-Path $outputPath "README.dev.md")) {
|
||||
throw "Public export isolation failed; README.dev.md was copied."
|
||||
}
|
||||
|
||||
Write-Host "GitHub export prepared:"
|
||||
Write-Host " $outputPath"
|
||||
Write-Host ""
|
||||
|
||||
Reference in New Issue
Block a user