清理废弃模块并完善测试基础设施
This commit is contained in:
@@ -25,4 +25,6 @@ cmake-build-*/
|
|||||||
/config/
|
/config/
|
||||||
/logs/
|
/logs/
|
||||||
/reports/perf/
|
/reports/perf/
|
||||||
|
/reports/qa/
|
||||||
|
/internal/qa/out/
|
||||||
*.broken.json
|
*.broken.json
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|||||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
set(CMAKE_AUTOMOC OFF)
|
set(CMAKE_AUTOMOC OFF)
|
||||||
set(CMAKE_AUTORCC ON)
|
|
||||||
set(CMAKE_AUTOUIC ON)
|
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
|
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
|
||||||
|
|
||||||
@@ -50,20 +48,6 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/config/ConfigManager.cpp
|
src/config/ConfigManager.cpp
|
||||||
src/config/SecretStore.h
|
src/config/SecretStore.h
|
||||||
src/config/SecretStore.cpp
|
src/config/SecretStore.cpp
|
||||||
src/fileops/FileBackupManager.h
|
|
||||||
src/fileops/FileBackupManager.cpp
|
|
||||||
src/fileops/FileOperationManager.h
|
|
||||||
src/fileops/FileOperationManager.cpp
|
|
||||||
src/fileops/FileOperationTypes.h
|
|
||||||
src/fileops/FileSandbox.h
|
|
||||||
src/fileops/FileSandbox.cpp
|
|
||||||
src/launcher/AppDiscovery.h
|
|
||||||
src/launcher/AppDiscovery.cpp
|
|
||||||
src/launcher/AppLaunchManager.h
|
|
||||||
src/launcher/AppLaunchManager.cpp
|
|
||||||
src/launcher/AppLaunchStore.h
|
|
||||||
src/launcher/AppLaunchStore.cpp
|
|
||||||
src/launcher/AppLaunchTypes.h
|
|
||||||
src/notification/NotificationDispatcher.h
|
src/notification/NotificationDispatcher.h
|
||||||
src/notification/NotificationDispatcher.cpp
|
src/notification/NotificationDispatcher.cpp
|
||||||
src/reminder/ReminderCommandHandler.h
|
src/reminder/ReminderCommandHandler.h
|
||||||
@@ -82,8 +66,6 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/reminder/ReminderTypes.cpp
|
src/reminder/ReminderTypes.cpp
|
||||||
src/state/PetStateMachine.h
|
src/state/PetStateMachine.h
|
||||||
src/state/PetStateMachine.cpp
|
src/state/PetStateMachine.cpp
|
||||||
src/system/GlobalHotkeyManager.h
|
|
||||||
src/system/GlobalHotkeyManager.cpp
|
|
||||||
src/system/StartupManager.h
|
src/system/StartupManager.h
|
||||||
src/system/StartupManager.cpp
|
src/system/StartupManager.cpp
|
||||||
src/tray/TrayController.h
|
src/tray/TrayController.h
|
||||||
@@ -98,10 +80,6 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/ui/PetView.cpp
|
src/ui/PetView.cpp
|
||||||
src/ui/SettingsDialog.h
|
src/ui/SettingsDialog.h
|
||||||
src/ui/SettingsDialog.cpp
|
src/ui/SettingsDialog.cpp
|
||||||
src/ui/WorkspaceAgentWindow.h
|
|
||||||
src/ui/WorkspaceAgentWindow.cpp
|
|
||||||
src/ui/WorkspacePanel.h
|
|
||||||
src/ui/WorkspacePanel.cpp
|
|
||||||
src/ui/PetWindow.h
|
src/ui/PetWindow.h
|
||||||
src/ui/PetWindow.cpp
|
src/ui/PetWindow.cpp
|
||||||
src/util/Logger.h
|
src/util/Logger.h
|
||||||
@@ -118,34 +96,6 @@ qt_add_executable(QtDesktopPet
|
|||||||
src/weather/WeatherSummaryFormatter.h
|
src/weather/WeatherSummaryFormatter.h
|
||||||
src/weather/WeatherSummaryFormatter.cpp
|
src/weather/WeatherSummaryFormatter.cpp
|
||||||
src/weather/WeatherTypes.h
|
src/weather/WeatherTypes.h
|
||||||
src/workspace/WorkspaceAccessPolicy.h
|
|
||||||
src/workspace/WorkspaceAccessPolicy.cpp
|
|
||||||
src/workspace/WorkspaceAgent.h
|
|
||||||
src/workspace/WorkspaceAgent.cpp
|
|
||||||
src/workspace/WorkspaceController.h
|
|
||||||
src/workspace/WorkspaceController.cpp
|
|
||||||
src/workspace/WorkspaceIndex.h
|
|
||||||
src/workspace/WorkspaceIndex.cpp
|
|
||||||
src/workspace/WorkspaceScanner.h
|
|
||||||
src/workspace/WorkspaceScanner.cpp
|
|
||||||
src/workspace/WorkspaceSession.h
|
|
||||||
src/workspace/WorkspaceSession.cpp
|
|
||||||
src/workspace/WorkspaceTool.h
|
|
||||||
src/workspace/WorkspaceTypes.h
|
|
||||||
src/workspace/tools/ListFilesTool.h
|
|
||||||
src/workspace/tools/ListFilesTool.cpp
|
|
||||||
src/workspace/tools/OpenDirectoryTool.h
|
|
||||||
src/workspace/tools/OpenDirectoryTool.cpp
|
|
||||||
src/workspace/tools/OpenFileTool.h
|
|
||||||
src/workspace/tools/OpenFileTool.cpp
|
|
||||||
src/workspace/tools/ReadFileTool.h
|
|
||||||
src/workspace/tools/ReadFileTool.cpp
|
|
||||||
src/workspace/tools/RevealInFileManagerTool.h
|
|
||||||
src/workspace/tools/RevealInFileManagerTool.cpp
|
|
||||||
src/workspace/tools/SearchFilesTool.h
|
|
||||||
src/workspace/tools/SearchFilesTool.cpp
|
|
||||||
src/workspace/tools/SearchTextTool.h
|
|
||||||
src/workspace/tools/SearchTextTool.cpp
|
|
||||||
src/web/WebCapabilityDetector.h
|
src/web/WebCapabilityDetector.h
|
||||||
src/web/WebCapabilityDetector.cpp
|
src/web/WebCapabilityDetector.cpp
|
||||||
src/web/WebChatManager.h
|
src/web/WebChatManager.h
|
||||||
|
|||||||
+15
-100
@@ -40,9 +40,7 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
|
|||||||
- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知
|
- 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知
|
||||||
- 提醒音效切换、试听、用户 wav 导入和删除
|
- 提醒音效切换、试听、用户 wav 导入和删除
|
||||||
- 天气查询 v1:Open-Meteo 天气源、城市解析、默认城市、公网 IP 定位兜底和模板回复
|
- 天气查询 v1:Open-Meteo 天气源、城市解析、默认城市、公网 IP 定位兜底和模板回复
|
||||||
- 本地文件操作 v1:读取文本文件、列出文件夹、复制、备份、重命名
|
|
||||||
- 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding
|
- 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding
|
||||||
- 应用启动 v1:通过聊天打开已登记应用、开始菜单快捷方式或用户确认选择的 `.exe`
|
|
||||||
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
||||||
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
|
- Windows GUI 子系统,Release exe 双击不弹控制台窗口
|
||||||
|
|
||||||
@@ -50,9 +48,11 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目
|
|||||||
|
|
||||||
- 长期性能压测记录
|
- 长期性能压测记录
|
||||||
- 发布包实机安装/卸载验证
|
- 发布包实机安装/卸载验证
|
||||||
- 文件操作 zip 打包、删除、覆盖、移动、脚本/命令执行
|
|
||||||
- 搜索网页全文抓取、长期缓存和浏览器自动打开网页
|
- 搜索网页全文抓取、长期缓存和浏览器自动打开网页
|
||||||
- 应用启动脚本、命令行参数、管理员权限和跨平台应用发现
|
|
||||||
|
当前非目标:
|
||||||
|
|
||||||
|
- 本地文件访问、应用启动、命令执行和工作区 Agent;这些能力已从当前产品方向移除
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -292,71 +292,6 @@ QStandardPaths::AppDataLocation/sounds/reminders/
|
|||||||
- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份
|
- 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份
|
||||||
- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除
|
- 用户音效删除仅允许删除用户音效目录内的安全 sound id,内置音效和非法路径不会被删除
|
||||||
|
|
||||||
## 本地文件操作
|
|
||||||
|
|
||||||
本地文件操作通过聊天意图触发,但不会直接使用聊天文本里的路径。所有文件和文件夹都必须由用户在系统文件选择框中主动选择。
|
|
||||||
|
|
||||||
当前 v1 支持:
|
|
||||||
|
|
||||||
- 读取用户选择的常见文本文件:txt、md、log、json、csv、ini、xml、yaml
|
|
||||||
- 列出用户选择的文件夹,最多显示前 200 项
|
|
||||||
- 复制用户选择的文件到用户选择的目标文件夹
|
|
||||||
- 为用户选择的文件创建同目录备份
|
|
||||||
- 重命名用户选择的文件
|
|
||||||
|
|
||||||
安全规则:
|
|
||||||
|
|
||||||
- 写操作会先展示操作计划并要求确认
|
|
||||||
- 不覆盖已有文件
|
|
||||||
- 不删除文件
|
|
||||||
- 不移动大量文件
|
|
||||||
- 不执行脚本或命令
|
|
||||||
- 不访问 Windows 系统目录
|
|
||||||
- 拒绝符号链接或符号链接目录内的路径
|
|
||||||
|
|
||||||
暂不支持:
|
|
||||||
|
|
||||||
- zip 打包
|
|
||||||
- 删除、覆盖、移动
|
|
||||||
- 修改源码
|
|
||||||
- 执行脚本或命令
|
|
||||||
|
|
||||||
## 本地工作区 Agent v1b 独立窗口
|
|
||||||
|
|
||||||
本地工作区 Agent 是旧文件操作 v1 的后续替代方向。当前已接入 v1a.1/v1b/v1b.5,并完成独立窗口化:工作区路径只保存在本次运行内,不执行任何写操作。
|
|
||||||
|
|
||||||
当前支持:
|
|
||||||
|
|
||||||
- `打开工作区 / 选择工作区`:用户主动选择一个目录后,程序扫描并建立内存索引
|
|
||||||
- `当前工作区 / 扫描工作区`:查看当前工作区和重新扫描
|
|
||||||
- `搜索 README / 搜索 main.cpp`:按文件名搜索,最多显示 30 个结果
|
|
||||||
- `搜索包含 class 的文件 / 含class的文件 / 找一下含 class 的文件`:搜索文本内容,显示路径、行号和片段
|
|
||||||
- `读取 main.cpp / 读取第一个`:读取工作区内文本文件,最多显示前 128KB
|
|
||||||
- `总结这个文件 / 分析这个文件 / 解释 main.cpp / main.cpp 是干嘛的`:使用最近读取或指定工作区文件;AI 可用且空闲时补简短分析
|
|
||||||
- `分析这个项目`:先显示本地结构摘要;AI 可用且空闲时再用独立工作区提示词补简短分析
|
|
||||||
- `打开 README / 打开第一个`:打开工作区内普通文件,打开前二次确认
|
|
||||||
- `打开 docs / 显示 main.cpp 所在位置 / 打开所在文件夹`:打开工作区内文件夹或文件所在目录
|
|
||||||
- 独立 Agent 窗口:左侧展示文件结果、文本命中和上下文,中间展示用户任务、本地工具结果和 AI 分析,右侧展示选中项详情、文本命中详情、最近读取预览以及 v1c 预留的变更计划/Diff 空状态
|
|
||||||
- 窗口入口:桌宠右键菜单、托盘菜单和全局快捷键,默认 `Alt+A`
|
|
||||||
- 设置页 `Agent` 页:可启用/关闭窗口快捷键、修改快捷键、控制工作区命令完成后是否自动显示窗口
|
|
||||||
|
|
||||||
安全规则:
|
|
||||||
|
|
||||||
- 工作区只能由用户通过目录选择框选择
|
|
||||||
- 拒绝 Windows 系统目录、符号链接目录和工作区外路径
|
|
||||||
- 跳过 `.git`、`build`、`node_modules`、`dist`、`release_packages` 等目录
|
|
||||||
- 默认拒绝读取 `.env`、密钥、凭证和包含 `secret/token/password/credential/private/api_key` 等关键词的敏感文件
|
|
||||||
- 拒绝打开 `.exe/.bat/.cmd/.ps1/.vbs/.msi/.scr/.reg/.com/.pif/.lnk`
|
|
||||||
- `读取文件` 这类泛泛请求仍走旧 `src/fileops/`;工作区读取建议使用 `读取 main.cpp` 或 `读取这个项目里的 README`
|
|
||||||
- `打开工作区 / 选择工作区` 表示重新选择目录;查看状态使用 `当前工作区`
|
|
||||||
- `创建 test.txt / 新建文件 / 写入 README / 修改 main.cpp / 修复 main.cpp / 优化 main.cpp` 当前只提示不支持,不弹读取文件选择框
|
|
||||||
- 不执行命令,不创建/修改/移动/删除文件
|
|
||||||
|
|
||||||
完整施工计划见:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/agent/DesktopPet_本地工作区Agent_施工计划.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 联网模式
|
## 联网模式
|
||||||
|
|
||||||
@@ -388,38 +323,12 @@ QStandardPaths::AppConfigLocation/web_config.json
|
|||||||
- 结构化搜索 API 或自建联网后端。
|
- 结构化搜索 API 或自建联网后端。
|
||||||
- 更细粒度的来源证据摘录和引用 UI。
|
- 更细粒度的来源证据摘录和引用 UI。
|
||||||
|
|
||||||
## 应用启动
|
## 当前能力边界
|
||||||
|
|
||||||
应用启动是独立能力,不归入本地文件操作。可通过聊天输入:
|
为保持桌宠产品聚焦,当前版本已移除聊天驱动的本地文件操作、应用启动和工作区 Agent。相关聊天内容只会进入普通 AI 对话或现有的提醒/天气意图,不会弹出本地路径选择器、启动程序、扫描项目或执行命令。
|
||||||
|
|
||||||
```text
|
应用自身的“开机自启动”仍保留,由设置页和安装器选项管理。旧版 `launcher_config.json` 与 `app_config.json` 中的 `agent` 字段不会再读取或写入,但程序不会主动删除用户目录中的旧文件。
|
||||||
打开 Codex
|
|
||||||
启动酷狗音乐
|
|
||||||
帮我打开 VSCode
|
|
||||||
```
|
|
||||||
|
|
||||||
配置保存到:
|
|
||||||
|
|
||||||
```text
|
|
||||||
QStandardPaths::AppConfigLocation/launcher_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
设置页“应用启动”支持:
|
|
||||||
|
|
||||||
- 启用或关闭应用启动
|
|
||||||
- 未知应用允许用户手动选择 `.exe`
|
|
||||||
- 登记应用名称和别名
|
|
||||||
- 编辑、删除、测试启动已登记应用
|
|
||||||
|
|
||||||
安全边界:
|
|
||||||
|
|
||||||
- 启动前始终二次确认,确认内容包含应用名、路径、工作目录和来源
|
|
||||||
- 只允许启动 `.exe` 或开始菜单 `.lnk` 快捷方式
|
|
||||||
- 不执行 `.bat` / `.cmd` / `.ps1` / `.vbs` / `.js` / `.msi`
|
|
||||||
- 不执行聊天文本里的命令
|
|
||||||
- 不拼接聊天文本参数
|
|
||||||
- 不以管理员权限启动
|
|
||||||
- 手选 `.exe` 后可选择记住为当前名称,后续走登记应用匹配
|
|
||||||
|
|
||||||
## 配置和日志
|
## 配置和日志
|
||||||
|
|
||||||
@@ -437,7 +346,6 @@ ai_config.broken.yyyyMMdd-HHmmss.json
|
|||||||
conversation_history.broken.yyyyMMdd-HHmmss.json
|
conversation_history.broken.yyyyMMdd-HHmmss.json
|
||||||
weather_config.broken.yyyyMMdd-HHmmss.json
|
weather_config.broken.yyyyMMdd-HHmmss.json
|
||||||
web_config.broken.yyyyMMdd-HHmmss.json
|
web_config.broken.yyyyMMdd-HHmmss.json
|
||||||
launcher_config.broken.yyyyMMdd-HHmmss.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
日志输出到文件,不输出到控制台:
|
日志输出到文件,不输出到控制台:
|
||||||
@@ -550,7 +458,7 @@ C:\Program Files (x86)\Inno Setup 6\ISCC.exe
|
|||||||
仓库提供开发用性能采样脚本,不进入普通用户发布包:
|
仓库提供开发用性能采样脚本,不进入普通用户发布包:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
|
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m
|
||||||
```
|
```
|
||||||
|
|
||||||
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到:
|
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到:
|
||||||
@@ -565,6 +473,13 @@ reports/perf/
|
|||||||
docs/performance_stability_check.md
|
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` 和必要说明。
|
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。
|
||||||
|
|
||||||
## AI 配置和聊天
|
## AI 配置和聊天
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# QtDesktopPet
|
# QtDesktopPet
|
||||||
|
|
||||||
一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、本地提醒、天气查询、简单文件操作和本地应用启动等能力。
|
一个基于 **Qt 6 Widgets / C++17** 的 Windows 桌面宠物项目。它提供透明桌宠窗口、PNG 序列帧动画、多状态切换、托盘控制、AI 对话、AI 原生联网、本地提醒和天气查询等能力。
|
||||||
|
|
||||||
> 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。
|
> 当前仓库仍处于活跃开发阶段。角色素材、图标和音效的再分发权限需要在正式公开发布前单独确认。
|
||||||
|
|
||||||
@@ -31,30 +31,13 @@
|
|||||||
- Open-Meteo Forecast API
|
- Open-Meteo Forecast API
|
||||||
- Open-Meteo Geocoding API
|
- Open-Meteo Geocoding API
|
||||||
- 默认城市、公网 IP 定位兜底、多候选提示
|
- 默认城市、公网 IP 定位兜底、多候选提示
|
||||||
- 本地文件操作 v1:
|
|
||||||
- 读取文本文件
|
|
||||||
- 列出文件夹
|
|
||||||
- 复制、备份、重命名
|
|
||||||
- 写操作前二次确认
|
|
||||||
- 本地工作区 Agent v1b:
|
|
||||||
- 用户选择工作区后扫描并建立内存索引
|
|
||||||
- 搜索文件名、搜索文本、读取工作区文本文件
|
|
||||||
- 项目结构摘要和文件内容总结
|
|
||||||
- 独立 Agent 窗口展示文件结果、文本命中、上下文、任务对话和详情预览
|
|
||||||
- 支持右键菜单、托盘菜单和默认 `Alt+A` 呼出窗口
|
|
||||||
- 打开工作区内普通文件、打开文件夹、显示文件所在位置
|
|
||||||
- 不执行命令,不创建/修改/移动/删除文件
|
|
||||||
- 应用启动 v1:
|
|
||||||
- 聊天触发打开本地应用
|
|
||||||
- 支持已登记应用、开始菜单快捷方式和用户手选 `.exe`
|
|
||||||
- 启动前二次确认
|
|
||||||
- Windows 发布脚本和 Inno Setup 安装器脚本。
|
- Windows 发布脚本和 Inno Setup 安装器脚本。
|
||||||
|
|
||||||
## Platform
|
## Platform
|
||||||
|
|
||||||
当前主要目标平台是 Windows 10 / Windows 11。
|
当前主要目标平台是 Windows 10 / Windows 11。
|
||||||
|
|
||||||
项目中已有部分跨平台基础代码,但托盘通知、开机自启动、应用发现和安装器体验目前按 Windows 优先实现。
|
项目中已有部分跨平台基础代码,但托盘通知、开机自启动和安装器体验目前按 Windows 优先实现。
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -83,8 +66,6 @@
|
|||||||
│ ├── assistant/ # Intent routing and command dispatch
|
│ ├── assistant/ # Intent routing and command dispatch
|
||||||
│ ├── character/ # Character package loading and animation
|
│ ├── character/ # Character package loading and animation
|
||||||
│ ├── config/ # Config persistence
|
│ ├── config/ # Config persistence
|
||||||
│ ├── fileops/ # Local file operations
|
|
||||||
│ ├── launcher/ # Local application launcher
|
|
||||||
│ ├── notification/ # Notification dispatch
|
│ ├── notification/ # Notification dispatch
|
||||||
│ ├── reminder/ # Reminder parser/store/scheduler/sounds
|
│ ├── reminder/ # Reminder parser/store/scheduler/sounds
|
||||||
│ ├── state/ # Pet state machine
|
│ ├── state/ # Pet state machine
|
||||||
@@ -93,9 +74,8 @@
|
|||||||
│ ├── ui/ # Widgets and main pet window
|
│ ├── ui/ # Widgets and main pet window
|
||||||
│ ├── util/
|
│ ├── util/
|
||||||
│ ├── weather/
|
│ ├── weather/
|
||||||
│ ├── web/ # AI-native web mode
|
│ └── web/ # AI-native web mode
|
||||||
│ └── workspace/ # Read-only local workspace agent
|
└── tools/ # Packaging and public-export scripts
|
||||||
└── tools/ # Packaging and diagnostic scripts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -155,7 +135,6 @@ Examples:
|
|||||||
- Reminders: `reminders.json`
|
- Reminders: `reminders.json`
|
||||||
- Weather config: `weather_config.json`
|
- Weather config: `weather_config.json`
|
||||||
- Web mode config: `web_config.json`
|
- Web mode config: `web_config.json`
|
||||||
- Launcher config: `launcher_config.json`
|
|
||||||
|
|
||||||
The app writes rotating logs under:
|
The app writes rotating logs under:
|
||||||
|
|
||||||
@@ -177,14 +156,10 @@ Important notes:
|
|||||||
|
|
||||||
## Safety Boundaries
|
## Safety Boundaries
|
||||||
|
|
||||||
The project intentionally keeps local automation conservative:
|
- 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.
|
||||||
- File operations require user-selected paths.
|
- Windows startup integration only manages this application's current-user `Run` entry.
|
||||||
- Write operations require confirmation.
|
- AI and web requests are sent only to providers configured by the user.
|
||||||
- File operations do not execute scripts or commands.
|
|
||||||
- Application launch only supports `.exe` and Start Menu `.lnk` shortcuts.
|
|
||||||
- Chat text is not converted into shell commands.
|
|
||||||
- Startup integration writes only the current user's Windows `Run` entry.
|
|
||||||
|
|
||||||
## Web Mode
|
## Web Mode
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +1,70 @@
|
|||||||
# QtDesktopPet 当前路线与结构收口
|
# QtDesktopPet 当前路线与结构收口
|
||||||
|
|
||||||
本文档是当前有效的开发路线总览。旧版长文档已归档到 `docs/archive/QtDesktopPet_后续功能规划与结构审查_旧版.md`。
|
更新时间:2026-07-16
|
||||||
|
|
||||||
## 当前产品状态
|
## 产品定位
|
||||||
|
|
||||||
项目当前是一个 Windows 优先的 Qt 6 Widgets 桌面宠物应用,主要能力包括:
|
QtDesktopPet 当前定位为轻量 Windows 桌面宠物,核心体验是角色动画、简短 AI 对话、AI 原生联网、本地提醒和天气查询。产品不再承担本地文件管理、应用启动或代码工作区 Agent 职责。
|
||||||
|
|
||||||
- 透明无边框桌宠窗口、拖动、置顶、托盘和单实例唤醒。
|
## 已落地模块
|
||||||
- PNG 序列帧角色包、多状态动画、角色导入、切换、导出和用户角色目录管理。
|
|
||||||
- AI 对话,支持 OpenAI Compatible、OpenAI 官方、Google Gemini、DeepSeek、Custom 配置。
|
|
||||||
- 输入框联网模式,使用 AI Provider 原生联网能力,不再维护旧搜索引擎聚合。
|
|
||||||
- 定时提醒,支持一次性和每天/每周/每月重复提醒、音效管理、历史保留和隐藏/AI 忙通知规则。
|
|
||||||
- 天气查询,使用 Open-Meteo、默认城市、IP fallback、多候选提示和默认城市测试。
|
|
||||||
- 对话历史管理,支持本地保存、关键词搜索、Provider/模型/时间筛选、Markdown/JSON 导出。
|
|
||||||
- 本地应用启动,支持登记应用、别名、手选 `.exe`、启动前确认和开机自启动配置。
|
|
||||||
- 本地文件操作 v1,支持读取文本、列目录、复制、备份和重命名。
|
|
||||||
- 本地工作区 Agent v1b,支持选择工作区、扫描索引、搜索文件、搜索文本、读取文本文件、项目结构摘要、文件内容总结、打开普通文件、显示所在位置和独立 Agent 窗口。
|
|
||||||
|
|
||||||
## 当前结构边界
|
| 模块 | 主要实现 | 当前状态 |
|
||||||
|
|
||||||
已经形成的模块边界:
|
|
||||||
|
|
||||||
| 模块 | 目录 | 状态 |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| AI 对话 | `src/ai/` | 已落地 |
|
| 桌宠与动画 | `src/ui/`、`src/character/`、`src/state/` | 透明窗口、拖动、托盘、单实例、多状态动画、懒加载和缓存管理已落地 |
|
||||||
| 意图分发 | `src/assistant/` | 已落地 |
|
| AI 对话 | `src/ai/` | OpenAI-compatible、Gemini、DeepSeek/Custom 配置、流式输出、取消和历史管理已落地 |
|
||||||
| 角色包 | `src/character/` | 已落地 |
|
| 联网模式 | `src/web/` | OpenAI 官方 Web Search 与 Gemini Google Search grounding 已落地;不支持 Provider 明确提示 |
|
||||||
| 配置与密钥 | `src/config/` | 已落地 |
|
| 提醒 | `src/reminder/`、`src/notification/` | 一次性和日/周/月重复、编辑 pending、20 天历史、音效、通知和兜底调度已落地 |
|
||||||
| 文件操作 v1 | `src/fileops/` | 已落地,后续将被 Workspace Agent 替代 |
|
| 天气 | `src/weather/` | Open-Meteo、默认城市、IP fallback、多候选提示和默认城市测试已落地 |
|
||||||
| 应用启动 | `src/launcher/` | 已落地 |
|
| 角色管理 | `src/character/`、设置页 | 导入、验证、切换、删除用户角色、导出和打开用户角色目录已落地 |
|
||||||
| 通知 | `src/notification/` | 已落地 |
|
| 配置与发布 | `src/config/`、`src/system/`、`tools/`、`installer/` | 原子/兼容配置、开机自启动、打包和安装脚本已落地 |
|
||||||
| 提醒 | `src/reminder/` | 已落地 |
|
|
||||||
| 状态机 | `src/state/` | 已落地 |
|
|
||||||
| 系统能力 | `src/system/` | 已落地 |
|
|
||||||
| 天气 | `src/weather/` | 已落地 |
|
|
||||||
| 联网模式 | `src/web/` | 已落地 |
|
|
||||||
| 本地工作区 Agent | `src/workspace/` + `src/ui/WorkspaceAgentWindow.*` | v1a.1/v1b/v1b.5 独立窗口化已落地 |
|
|
||||||
|
|
||||||
`PetWindow` 仍然承担较多 UI 编排职责。后续新增大功能必须优先放在独立模块和 Controller 中,`PetWindow` 只保留入口、展示和确认交互。
|
## 已移除模块
|
||||||
|
|
||||||
## 下一阶段主线
|
以下模块已从源码、CMake、意图分发、设置页和托盘入口中删除:
|
||||||
|
|
||||||
下一阶段主线仍是本地工作区 Agent,而不是继续扩展旧 `src/fileops/`。
|
- 聊天驱动的本地文件操作。
|
||||||
|
- 本地应用启动与应用登记。
|
||||||
|
- 本地工作区 Agent、独立 Agent 窗口和 `Alt+A` 全局快捷键。
|
||||||
|
|
||||||
v1a.1/v1b/v1b.5 已完成:
|
删除原因是这些能力显著扩大安全边界和维护面,并使桌宠产品偏离轻量助手定位。应用自身的“开机自启动”不属于应用启动模块,继续保留。
|
||||||
|
|
||||||
- 用户选择一个本地工作区目录。
|
兼容策略:程序不主动删除用户目录中的旧 `launcher_config.json`;旧 `app_config.json` 中的 `agent` 对象会被忽略,后续保存时不再写回。
|
||||||
- 桌宠可以扫描、搜索、读取和分析该工作区内文件。
|
|
||||||
- 桌宠可以打开工作区内普通文件、打开文件夹、显示文件所在位置。
|
|
||||||
- 独立 Agent 窗口可以展示文件结果、文本命中、最近读取、用户任务、本地工具结果和 AI 分析结果,并对选中结果执行读取、打开、显示位置。
|
|
||||||
- 写操作误分发已收口:创建/新建/写入/修改不会再误走旧 fileops 的读取文本文件流程。
|
|
||||||
- 文本搜索误分发已收口:`含class的文件 / 包含 class 的文件 / 找一下含 class 的文件` 会走工作区全文搜索,不再弹旧文本文件选择框。
|
|
||||||
|
|
||||||
后续目标:
|
## 当前意图边界
|
||||||
|
|
||||||
- 桌宠可以生成文件修改计划。
|
聊天意图优先级固定为:
|
||||||
- 创建、修改、复制、移动、重命名、删除到回收站等写操作必须先展示计划并等待用户确认。
|
|
||||||
- 不允许访问工作区外路径。
|
|
||||||
- 不允许执行系统命令、脚本或聊天文本中的命令参数。
|
|
||||||
- 不允许永久删除文件。
|
|
||||||
|
|
||||||
正式施工计划见:
|
```text
|
||||||
|
Reminder > Weather > Chat/WebChat
|
||||||
|
```
|
||||||
|
|
||||||
`docs/agent/DesktopPet_本地工作区Agent_施工计划.md`
|
- 含“提醒”等明确提醒表达时进入提醒模块。
|
||||||
|
- 含天气、气温、降雨等表达时进入天气模块。
|
||||||
|
- 其余内容进入普通 AI 对话;输入框联网开关开启且 Provider 支持时进入联网对话。
|
||||||
|
- “读取文件”“打开 Codex”“分析这个项目”等文本不会触发本地系统能力。
|
||||||
|
|
||||||
## 功能优先级建议
|
## 下一阶段建议
|
||||||
|
|
||||||
当前聊天意图建议保持以下原则:
|
优先完成稳定性和发布收口,不再横向扩展高权限本地自动化:
|
||||||
|
|
||||||
1. 提醒明确优先,例如“明天提醒我看天气”应创建提醒。
|
1. 完成全量手动回归,重点覆盖提醒调度、联网 Provider 差异、天气失败路径和旧配置兼容。
|
||||||
2. 天气明确问答走天气模块,例如“明天西安天气怎么样”。
|
2. 完成 Release 包、安装、卸载、开机自启动和无 Qt 开发环境运行验证。
|
||||||
3. 工作区相关文件和代码任务走 Workspace Agent,例如“搜索这个项目里的 README”“读取 main.cpp”“分析这个项目”;重命名等写操作留到后续阶段。
|
3. 完成静置、隐藏、动画缓存、AI 连续对话、提醒、天气和联网请求的性能采样。
|
||||||
4. 明确应用启动走 Launcher,例如“打开 Codex”“启动酷狗音乐”。
|
4. 根据实测问题修复稳定性,不新增复杂提醒规则、网页抓取或高权限本地能力。
|
||||||
5. 其余走普通聊天或联网聊天。
|
5. 确认角色、图标、音效素材的公开再分发授权。
|
||||||
|
|
||||||
不能简单用“文件 / 打开 / 搜索”这类宽关键词吞掉所有请求。尤其要避免:
|
## 延期或非目标
|
||||||
|
|
||||||
- “打开微信”误判为工作区打开文件。
|
- 语音输入输出、Live2D、GIF/视频角色。
|
||||||
- “搜索 Qt 最新版本”误判为工作区搜索。
|
- 插件系统、云同步、自动更新、长期记忆和角色市场。
|
||||||
- “把天气截图保存到桌面”误判为天气查询。
|
- 空气质量、天气预警、国内天气 Provider 和天气提醒联动。
|
||||||
|
- 复杂提醒规则、跨平台通知和自定义稍后提醒间隔。
|
||||||
## 当前延期项
|
- 网页全文抓取、搜索引擎页面解析、自建结构化搜索后端。
|
||||||
|
- 本地文件访问、应用启动、命令执行和工作区 Agent。
|
||||||
以下能力继续延期,不进入本地工作区 Agent 第一阶段:
|
|
||||||
|
|
||||||
- 语音输入和语音朗读。
|
|
||||||
- Live2D、GIF、视频形象。
|
|
||||||
- 插件系统。
|
|
||||||
- 云同步和自动更新。
|
|
||||||
- 长期记忆系统。
|
|
||||||
- 角色市场或角色包分享平台。
|
|
||||||
- Shell 命令执行、脚本执行、自动安装依赖。
|
|
||||||
- 自动 git commit、自动推送、自动联网下载并执行文件。
|
|
||||||
- 工作区外全盘搜索。
|
|
||||||
- 复杂网页抓取和旧搜索引擎聚合。
|
|
||||||
|
|
||||||
## 验收策略
|
## 验收策略
|
||||||
|
|
||||||
- Codex 不调用 CMake、`cmake --build`、Qt Creator 构建或等价构建命令。
|
|
||||||
- 每完成一个模块,先做静态检查和逻辑推演。
|
|
||||||
- 编译和运行验证由用户手动执行。
|
- 编译和运行验证由用户手动执行。
|
||||||
- 功能验收按 `docs/QtDesktopPet_测试清单与验收标准.md`。
|
- 统一测试入口见 `docs/QtDesktopPet_测试清单与验收标准.md`。
|
||||||
- 性能和发布验收按 `docs/performance_stability_check.md`。
|
- 性能与发布验证入口见 `docs/performance_stability_check.md`。
|
||||||
|
- 每次能力调整后必须同步 README、测试清单、CMake 和设置页,避免残留不可达代码。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。
|
本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。
|
||||||
|
|
||||||
|
每次实际执行结果保存到 `docs/test-records/`,不得直接用某次结果覆盖本验收标准。内部自动化入口见 `internal/qa/README.md`。
|
||||||
|
|
||||||
## 验收记录格式
|
## 验收记录格式
|
||||||
|
|
||||||
每个用例建议记录:
|
每个用例建议记录:
|
||||||
@@ -24,7 +26,7 @@ Qt 版本:
|
|||||||
- 日志不包含完整 API Key、Authorization、完整用户消息正文、完整错误响应正文或完整搜索词。
|
- 日志不包含完整 API Key、Authorization、完整用户消息正文、完整错误响应正文或完整搜索词。
|
||||||
- 设置页保存后立即生效,重启后配置仍能读取。
|
- 设置页保存后立即生效,重启后配置仍能读取。
|
||||||
- 所有损坏 JSON 配置都应备份为 `.broken.yyyyMMdd-HHmmss.json` 后回退默认或忽略损坏数据。
|
- 所有损坏 JSON 配置都应备份为 `.broken.yyyyMMdd-HHmmss.json` 后回退默认或忽略损坏数据。
|
||||||
- 所有涉及本地文件写入的操作必须由用户选择路径,并在执行前显示计划和二次确认。
|
- 角色导入/导出、音效导入和历史导出只能使用用户明确选择的路径;覆盖或删除用户数据时必须确认。
|
||||||
- 不运行 CMake 的自动验证;构建和运行由用户手动完成。
|
- 不运行 CMake 的自动验证;构建和运行由用户手动完成。
|
||||||
|
|
||||||
## 基础窗口与托盘
|
## 基础窗口与托盘
|
||||||
@@ -37,6 +39,8 @@ Qt 版本:
|
|||||||
| 隐藏托盘 | 托盘菜单隐藏/显示 | 隐藏后动画暂停,显示后恢复 | TODO |
|
| 隐藏托盘 | 托盘菜单隐藏/显示 | 隐藏后动画暂停,显示后恢复 | TODO |
|
||||||
| 单实例 | 已运行时再次启动 exe | 只有一个进程,已有实例被唤醒 | TODO |
|
| 单实例 | 已运行时再次启动 exe | 只有一个进程,已有实例被唤醒 | TODO |
|
||||||
| 设置页居中 | 多屏或重复打开设置页 | 设置页出现在当前屏幕可用区域内并置前 | TODO |
|
| 设置页居中 | 多屏或重复打开设置页 | 设置页出现在当前屏幕可用区域内并置前 | TODO |
|
||||||
|
| 应用设置持久化 | 修改缩放、性能模式、隐藏暂停、懒加载和缓存设置后重启 | 设置立即生效,重启后保持,动画和窗口尺寸无异常 | TODO |
|
||||||
|
| 开机自启动 | 在应用页依次开启、重启检查、再关闭 | 开启时写入当前用户 Run 项且路径正确;关闭时移除;默认关闭 | TODO |
|
||||||
|
|
||||||
## 角色管理
|
## 角色管理
|
||||||
|
|
||||||
@@ -59,7 +63,7 @@ Qt 版本:
|
|||||||
| 连接测试 | 点击测试连接 | 成功显示成功;错误配置显示明确错误 | TODO |
|
| 连接测试 | 点击测试连接 | 成功显示成功;错误配置显示明确错误 | TODO |
|
||||||
| 普通聊天 | 发送短消息 | `think` 后进入 `talk`,气泡显示回复 | TODO |
|
| 普通聊天 | 发送短消息 | `think` 后进入 `talk`,气泡显示回复 | TODO |
|
||||||
| 流式回复 | 启用流式并发送消息 | 首段前保持 `think`,输出中保持 `talk` | TODO |
|
| 流式回复 | 启用流式并发送消息 | 首段前保持 `think`,输出中保持 `talk` | TODO |
|
||||||
| AI 忙 | 回复中再次发送聊天/天气/搜索 | 不启动第二个请求,显示忙提示 | TODO |
|
| AI 忙 | 回复中再次发送聊天/天气/联网请求 | 不启动第二个请求,显示忙提示 | TODO |
|
||||||
| 取消 AI | 右键取消 AI 请求 | 请求取消,状态恢复,气泡提示取消 | TODO |
|
| 取消 AI | 右键取消 AI 请求 | 请求取消,状态恢复,气泡提示取消 | TODO |
|
||||||
| 历史保存 | 开启本地保存后聊天 | 重启后历史可读取 | TODO |
|
| 历史保存 | 开启本地保存后聊天 | 重启后历史可读取 | TODO |
|
||||||
| 历史搜索 | 在设置页按关键词搜索 | 只显示匹配记录 | TODO |
|
| 历史搜索 | 在设置页按关键词搜索 | 只显示匹配记录 | TODO |
|
||||||
@@ -67,6 +71,7 @@ Qt 版本:
|
|||||||
| 导出 Markdown | 导出筛选结果为 `.md` | 文件包含角色、时间、Provider/模型和正文,不含 API Key | TODO |
|
| 导出 Markdown | 导出筛选结果为 `.md` | 文件包含角色、时间、Provider/模型和正文,不含 API Key | TODO |
|
||||||
| 导出 JSON | 导出筛选结果为 `.json` | JSON 可解析,包含 messages 数组,不含 API Key | TODO |
|
| 导出 JSON | 导出筛选结果为 `.json` | JSON 可解析,包含 messages 数组,不含 API Key | TODO |
|
||||||
| 清空历史 | 点击清空聊天记录并确认 | 内存和本地历史清空,面板刷新 | TODO |
|
| 清空历史 | 点击清空聊天记录并确认 | 内存和本地历史清空,面板刷新 | TODO |
|
||||||
|
| 关闭历史保存 | 关闭本地保存后聊天并重启 | 本轮消息只在内存存在,重启后不写入本地历史 | TODO |
|
||||||
|
|
||||||
## 定时提醒
|
## 定时提醒
|
||||||
|
|
||||||
@@ -90,6 +95,8 @@ Qt 版本:
|
|||||||
| 历史清理 | 构造 21 天前 triggered/canceled 后清理 | 删除 20 天前历史,保留最近 20 天和 pending | TODO |
|
| 历史清理 | 构造 21 天前 triggered/canceled 后清理 | 删除 20 天前历史,保留最近 20 天和 pending | TODO |
|
||||||
| 音效导入 | 导入合法 PCM wav | 可选择、试听、删除 | TODO |
|
| 音效导入 | 导入合法 PCM wav | 可选择、试听、删除 | TODO |
|
||||||
| 内置音效保护 | 选择内置音效点击删除 | 删除按钮禁用或提示不可删除 | TODO |
|
| 内置音效保护 | 选择内置音效点击删除 | 删除按钮禁用或提示不可删除 | TODO |
|
||||||
|
| 睡眠与时间变化 | 睡眠跨过提醒时间;再分别模拟系统时间向前和向后变化 | 唤醒或向前跳变后补扫一次;向后跳变不提前触发 | TODO |
|
||||||
|
| 提醒保存失败 | 在隔离测试数据上模拟提醒文件不可写并等待到期 | 保持 pending,不播放、不通知、不显示气泡,后续仍可重试 | TODO |
|
||||||
|
|
||||||
## 天气查询
|
## 天气查询
|
||||||
|
|
||||||
@@ -106,73 +113,20 @@ Qt 版本:
|
|||||||
| 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO |
|
| 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO |
|
||||||
| 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO |
|
| 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO |
|
||||||
| 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO |
|
| 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO |
|
||||||
|
| 天气网络失败 | 断网或阻断天气请求后查询 | 明确提示网络/超时错误,UI 不阻塞,恢复网络后可再次查询 | TODO |
|
||||||
|
|
||||||
## 本地文件操作
|
## 已移除本地能力回归
|
||||||
|
|
||||||
当前源码仍保留安全受限的本地文件操作 v1。后续将由本地工作区 Agent 替代;替代完成前,以下用例仍作为回归项保留。
|
|
||||||
|
|
||||||
| 用例 | 步骤 | 预期结果 | 结论 |
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 读取文本 | 输入读取文本文件请求并选择 txt/md/log | 显示内容预览,最多读取前 64KB | TODO |
|
| 文件操作已移除 | 输入 `读取文件`、`创建 test.txt`、`复制文件` | 不弹文件选择框,不读取或修改本地文件;按普通 AI 对话处理 | TODO |
|
||||||
| 非文本拒绝 | 选择 exe/png 等非文本 | 提示只允许常见文本文件 | TODO |
|
| 应用启动已移除 | 输入 `打开 Codex`、`启动酷狗音乐` | 不弹 exe 选择框或确认框,不启动任何程序;按普通 AI 对话处理 | TODO |
|
||||||
| 列目录 | 输入列出文件夹请求并选择临时目录 | 显示前 200 项,区分目录/文件 | TODO |
|
| 工作区 Agent 已移除 | 输入 `打开工作区`、`分析这个项目`、`含 class 的文件` | 不扫描目录、不弹 Agent 窗口、不访问本地项目;按普通 AI 对话处理 | TODO |
|
||||||
| 复制文件 | 选择源文件和目标目录 | 显示计划,确认后复制;目标存在则拒绝覆盖 | TODO |
|
| 菜单入口清理 | 检查桌宠右键菜单和托盘菜单 | 不再出现 `Agent 工作区` 入口 | TODO |
|
||||||
| 创建备份 | 选择文件备份 | 显示计划,确认后生成 `.backup.yyyyMMdd-HHmmss` 文件 | TODO |
|
| 设置页清理 | 打开设置页 | 不再出现 `应用启动` 和 `Agent` 页面;应用页仍有 `开机自启动` | TODO |
|
||||||
| 重命名 | 选择文件并输入新文件名 | 显示计划,确认后重命名;目标存在拒绝 | TODO |
|
| 快捷键清理 | 按 `Alt+A` | 不再呼出 Agent 窗口,不影响其他快捷键或输入 | TODO |
|
||||||
| 危险词拒绝 | 输入删除/覆盖/移动/执行脚本/运行命令 | 明确拒绝,不弹路径选择,不做文件操作 | TODO |
|
| 旧配置兼容 | 用户目录保留旧 `launcher_config.json`,`app_config.json` 保留 `agent` 对象后启动 | 程序不崩溃,不读取旧能力配置;保存应用配置后不再写入 `agent` | TODO |
|
||||||
| 系统目录拒绝 | 尝试选择 Windows/Program Files 下路径 | 提示不允许访问系统目录 | TODO |
|
|
||||||
| 符号链接拒绝 | 选择符号链接或链接目录内路径 | 提示不允许操作符号链接路径 | TODO |
|
|
||||||
| zip 延期 | 输入打包/压缩请求 | 提示 zip 打包暂不启用 | TODO |
|
|
||||||
|
|
||||||
## 本地工作区 Agent
|
|
||||||
|
|
||||||
v1a.1/v1b/v1b.5 已接入工作区只读分析、打开定位和独立 Agent 窗口;创建、修改、移动、删除到回收站仍属后续阶段。正式施工计划见 `docs/agent/DesktopPet_本地工作区Agent_施工计划.md`。
|
|
||||||
|
|
||||||
| 用例 | 步骤 | 预期结果 | 结论 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 选择工作区 | 选择普通项目目录 | 显示当前工作区并扫描索引 | TODO |
|
|
||||||
| 系统目录拒绝 | 选择 `C:/Windows` 或 `Program Files` | 拒绝作为工作区 | TODO |
|
|
||||||
| 扫描跳过目录 | 工作区含 `.git/build/node_modules/release_packages` | 扫描跳过这些目录,结果数量受限制 | TODO |
|
|
||||||
| 搜索文件名 | 输入 `搜索 README` | 返回最多 30 个相对路径结果 | TODO |
|
|
||||||
| 独立 Agent 窗口 | 输入 `搜索 README` | 独立窗口展示上一轮结果,可选中结果;左侧结果/文本命中/上下文、中间对话、右侧详情分区清晰 | TODO |
|
|
||||||
| 窗口非客户区 | 打开独立 Agent 窗口 | 没有白色系统标题栏,标题区与整体深色 UI 协调,顶部可拖动移动窗口 | TODO |
|
|
||||||
| 搜索文本 | 输入 `搜索包含 class 的文件` | 返回路径、行号和片段,跳过二进制和敏感文件 | TODO |
|
|
||||||
| 紧凑文本搜索 | 输入 `含class的文件`、`包含 class 的文件`、`找一下含 class 的文件` | 走 Workspace 文本搜索,不弹旧文本文件选择框,关键词提取为 `class` | TODO |
|
|
||||||
| 文本命中窗口 | 输入 `搜索包含 class 的文件` | 独立窗口展示文本命中和关联文件列表,选中文本命中时右侧详情同步 | TODO |
|
|
||||||
| 读取文本文件 | 输入 `读取 main.cpp` | 读取工作区内文本文件,超大文件截断 | TODO |
|
|
||||||
| 窗口读取 | 搜索后在独立窗口选中结果并点击 `读取` | 读取选中文本文件,目录项读取按钮禁用 | TODO |
|
|
||||||
| 敏感文件拒绝 | 读取 `.env`、`*.pem`、`credentials.json` | 默认拒绝或强确认,不展示敏感内容 | TODO |
|
|
||||||
| 项目结构摘要 | 输入 `分析这个项目` | 显示本地结构摘要;AI 可用且空闲时补简短分析 | TODO |
|
|
||||||
| 文件总结 | 读取文件后输入 `总结这个文件` | 显示本地内容,AI 可用且空闲时补简短总结 | TODO |
|
|
||||||
| 文件分析同义词 | 读取 `main.cpp` 后输入 `分析这个文件`、`解释这个文件`、`看看这个文件` | 走 Workspace 最近读取文件分析,不弹旧文本文件选择框 | TODO |
|
|
||||||
| 文件名分析 | 输入 `分析 main.cpp`、`解释 README`、`main.cpp 是干嘛的` | 走 Workspace 文件读取和 AI 分析流程,不落入普通 AI 对话 | TODO |
|
|
||||||
| 总结无上下文 | 未读取工作区文件时输入 `总结这个文件` | 提示先读取一个工作区文件或输入具体文件名,不弹旧文件选择框 | TODO |
|
|
||||||
| 读取文件泛请求 | 输入 `读取文件` | 仍走旧 fileops 文本文件选择流程 | TODO |
|
|
||||||
| 上下文引用 | 先搜索 README,再输入 `读取第一个` | 正确引用上一轮结果;上下文过期则要求重新选择 | TODO |
|
|
||||||
| 打开文件 | 先搜索 README,再输入 `打开第一个` | 弹出确认框,确认后用系统默认程序打开;取消不打开 | TODO |
|
|
||||||
| 窗口打开 | 搜索后在独立窗口选中结果并点击 `打开` | 文件弹确认后打开;目录直接打开 | TODO |
|
|
||||||
| 打开目录 | 输入 `打开 docs` 或 `打开这个项目里的 docs` | 打开工作区内目录 | TODO |
|
|
||||||
| 显示位置 | 输入 `显示 main.cpp 所在位置` 或 `打开所在文件夹` | 打开工作区内文件所在目录 | TODO |
|
|
||||||
| 窗口显示位置 | 搜索后在独立窗口选中结果并点击 `位置` | 打开选中项所在目录 | TODO |
|
|
||||||
| 右键入口 | 右键桌宠选择 `Agent 工作区` | 独立 Agent 窗口打开;未选择工作区时显示空状态,不强制选择目录 | TODO |
|
|
||||||
| 托盘入口 | 托盘菜单选择 `Agent 工作区` | 独立 Agent 窗口打开;桌宠隐藏时也可直接打开窗口 | TODO |
|
|
||||||
| 默认快捷键 | 按 `Alt+A` | 呼出独立 Agent 窗口 | TODO |
|
|
||||||
| Agent 设置 | 设置页 `Agent` 页关闭快捷键或修改快捷键后保存 | 快捷键立即按新配置生效,非法/冲突快捷键有明确提示 | TODO |
|
|
||||||
| 自动显示设置 | 关闭 `工作区命令完成后自动显示 Agent 窗口` 后输入 `搜索 README` | 搜索正常完成,但独立窗口不自动弹出;手动入口仍可打开 | TODO |
|
|
||||||
| 打开敏感/危险文件拒绝 | 尝试打开 `.exe/.ps1/.lnk/.env/*.pem/credentials.json` | 明确拒绝,不调用系统打开 | TODO |
|
|
||||||
| 打开路径越界拒绝 | 尝试打开工作区外、系统目录、符号链接路径 | 明确拒绝 | TODO |
|
|
||||||
| 创建误分发修复 | 输入 `创建 test.txt` | 提示当前不支持创建/写入,不弹读取文本文件选择框 | TODO |
|
|
||||||
| 写入误分发修复 | 输入 `新建文件`、`写入 README`、`修改 main.cpp`、`修复 main.cpp`、`优化 main.cpp` | 明确拒绝当前阶段写操作,不弹读取文本文件选择框 | TODO |
|
|
||||||
| 危险能力拒绝 | 请求运行脚本、执行命令、自动安装依赖、自动 git commit | 明确拒绝,不调用系统命令 | TODO |
|
|
||||||
|
|
||||||
后续阶段验收项:
|
|
||||||
|
|
||||||
| 用例 | 步骤 | 预期结果 | 结论 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 创建文件 | 创建 `test.txt` | 展示计划,确认后创建;目标存在拒绝 | TODO |
|
|
||||||
| 修改文件 | 修改普通文本文件 | 展示计划,确认后写入;写入前生成备份 | TODO |
|
|
||||||
| 移动/重命名 | 移动或重命名单文件 | 展示计划,确认后执行;工作区外和覆盖目标拒绝 | TODO |
|
|
||||||
| 删除到回收站 | 删除普通文件 | 强确认后移动到回收站;永久删除和批量删除拒绝 | TODO |
|
|
||||||
|
|
||||||
## 联网模式
|
## 联网模式
|
||||||
|
|
||||||
@@ -189,23 +143,34 @@ v1a.1/v1b/v1b.5 已接入工作区只读分析、打开定位和独立 Agent 窗
|
|||||||
| 设置页状态 | 切换 OpenAI/Gemini/DeepSeek/Custom | “联网模式”页即时显示可用/不可用/无法确认状态 | TODO |
|
| 设置页状态 | 切换 OpenAI/Gemini/DeepSeek/Custom | “联网模式”页即时显示可用/不可用/无法确认状态 | TODO |
|
||||||
| 设置页测试 | 点击“测试联网模式” | 支持 Provider 发起测试,不支持 Provider 直接显示原因 | TODO |
|
| 设置页测试 | 点击“测试联网模式” | 支持 Provider 发起测试,不支持 Provider 直接显示原因 | TODO |
|
||||||
| 旧失败回归 | 打开联网后询问 `美国的首都在哪里` | 不再返回搜狗/360 帮助、登录、反馈页 | TODO |
|
| 旧失败回归 | 打开联网后询问 `美国的首都在哪里` | 不再返回搜狗/360 帮助、登录、反馈页 | TODO |
|
||||||
| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 仍优先走提醒或文件操作,不受联网开关影响 | TODO |
|
| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 前者走提醒;后者可走天气或普通对话,但绝不触发本地文件保存 | TODO |
|
||||||
|
| 联网开关双向记忆 | 启用记忆后分别执行关到开、开到关,不发送消息直接关闭并重开输入框 | 两个方向都保存最后状态;关闭记忆后使用默认开关值 | TODO |
|
||||||
|
| 联网网络失败 | 支持 Provider 下模拟断网、超时或服务端错误 | 明确提示失败,状态恢复,可重新发起请求,不泄露完整错误正文 | TODO |
|
||||||
|
|
||||||
## 应用启动
|
## 配置兼容与隐私
|
||||||
|
|
||||||
|
所有损坏测试都必须先备份用户数据,优先使用隔离测试账户或临时配置副本。
|
||||||
|
|
||||||
| 用例 | 步骤 | 预期结果 | 结论 |
|
| 用例 | 步骤 | 预期结果 | 结论 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 设置页登记 | 添加一个 `.exe`,填写名称和别名后保存 | 重启后登记项仍存在 | TODO |
|
| App 配置损坏 | 写入非法 `app_config.json` 后启动 | 原文件备份为带时间戳的 broken 文件,程序使用默认配置启动 | TODO |
|
||||||
| 已登记启动 | 输入 `打开 <别名>` | 弹出确认框,确认后启动对应 exe | TODO |
|
| AI 配置损坏 | 写入非法 `ai_config.json` 后启动 | 备份损坏文件,AI 回退未配置状态,不泄露旧 Key | TODO |
|
||||||
| 未知应用手选 | 输入 `打开酷狗音乐` 且未登记 | 弹出 exe 选择器,选择 `.exe` 后弹确认框 | TODO |
|
| 天气配置损坏 | 写入非法 `weather_config.json` 后启动 | 备份损坏文件,天气使用默认配置 | TODO |
|
||||||
| 记住手选应用 | 手选 exe 时勾选记住 | 后续同名请求直接命中登记应用并仍需确认 | TODO |
|
| 联网配置损坏 | 写入非法 `web_config.json` 后启动 | 备份损坏文件,联网开关使用默认配置 | TODO |
|
||||||
| 用户取消选择 | 未知应用弹出选择器后取消 | 不启动任何程序,无配置写入 | TODO |
|
| 提醒数据损坏 | 写入非法 `reminders.json` 后启动 | 备份损坏文件,不崩溃、不触发错误数据 | TODO |
|
||||||
| 用户取消确认 | 选择或命中应用后取消确认 | 不启动任何程序 | TODO |
|
| 历史数据损坏 | 写入非法 `conversation_history.json` 后打开历史 | 备份损坏文件并忽略,历史面板可继续使用 | TODO |
|
||||||
| 拒绝脚本 | 尝试选择 `.bat/.cmd/.ps1/.vbs/.js/.msi` | 被拒绝,不启动 | TODO |
|
| 日志隐私 | 覆盖正常请求、错误 Key、错误 Base URL、天气和联网失败 | 日志不含 API Key、Authorization、完整消息或完整错误响应 | TODO |
|
||||||
| 不执行命令 | 输入“运行 powershell 命令” | 不执行聊天文本命令;按安全规则拒绝或走普通聊天 | TODO |
|
|
||||||
| 文件操作优先 | 输入 `打开文件夹` | 走文件操作或文件操作拒绝路径,不走应用启动 | TODO |
|
## 自动化验证
|
||||||
| 设置页测试启动 | 选中登记应用点测试启动 | 先二次确认,确认后启动 | TODO |
|
|
||||||
| 设置页开机自启动 | 应用设置页勾选/取消“开机自启动”并保存 | 当前用户 Run 注册表项写入/移除,重开设置页状态与实际注册表一致 | TODO |
|
内部 QA 与发布构建隔离。用户配置好 Qt 测试构建后执行核心逻辑测试;不需要编译的静态检查可直接执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
当前自动化覆盖意图分发、提醒解析和类型、天气解析和模板、联网能力检测、聊天历史兼容,以及源码登记、废弃模块残留、默认资源和 GitHub 导出隔离。真实窗口、托盘通知、音效、网络 Provider、安装卸载仍按手测执行。
|
||||||
|
|
||||||
|
|
||||||
## 发布与性能
|
## 发布与性能
|
||||||
|
|
||||||
@@ -244,8 +209,6 @@ UI 状态:
|
|||||||
| 提醒 | `CommandDispatcher -> ReminderCommandHandler -> ReminderManager` | 创建/查询/取消一次性和 daily/weekly/monthly;到点先保存 JSON,再播放音效和展示/通知 | 保存失败回滚内存,不播放、不通知、不气泡;隐藏和 AI 忙只发系统通知;拖动中进入可见队列;20 天前历史可清理 | 系统通知是否真正弹出受 Windows/托盘后端影响,Qt 只能确认发起状态 | v2 行为保持,复杂重复规则、跨平台通知、自定义稍后间隔延期 |
|
| 提醒 | `CommandDispatcher -> ReminderCommandHandler -> ReminderManager` | 创建/查询/取消一次性和 daily/weekly/monthly;到点先保存 JSON,再播放音效和展示/通知 | 保存失败回滚内存,不播放、不通知、不气泡;隐藏和 AI 忙只发系统通知;拖动中进入可见队列;20 天前历史可清理 | 系统通知是否真正弹出受 Windows/托盘后端影响,Qt 只能确认发起状态 | v2 行为保持,复杂重复规则、跨平台通知、自定义稍后间隔延期 |
|
||||||
| 角色 | 设置页角色页和 `CharacterPackageRepository` | 导入先验证再复制;切换保存后立即生效;用户角色可删除;任意角色可导出;可打开用户角色目录 | 内置角色不可删除/覆盖;导出目标存在需二次确认;损坏角色包不复制 | 目标目录权限失败、用户取消、角色包资源缺失 | 当前角色管理补全完成,角色市场/分享延期 |
|
| 角色 | 设置页角色页和 `CharacterPackageRepository` | 导入先验证再复制;切换保存后立即生效;用户角色可删除;任意角色可导出;可打开用户角色目录 | 内置角色不可删除/覆盖;导出目标存在需二次确认;损坏角色包不复制 | 目标目录权限失败、用户取消、角色包资源缺失 | 当前角色管理补全完成,角色市场/分享延期 |
|
||||||
| 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 |
|
| 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 |
|
||||||
| 文件操作 | `CommandDispatcher -> PetWindow::handleFileOperationChatMessage() -> FileOperationManager` | 用户通过系统对话框选择路径;读取文本、列目录、复制、备份、重命名;写操作先展示计划并二次确认 | 删除/覆盖/移动/脚本/命令/截图保存/zip 直接拒绝;系统目录、符号链接、目标冲突、非法文件名均拒绝 | 文件选择对话框是安全边界;系统目录根据常见路径和环境变量识别 | v1 安全能力完成,zip 打包延期,不引入重依赖 |
|
| 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 |
|
||||||
| 本地工作区 Agent | `CommandDispatcher -> PetWindow::handleWorkspaceChatMessage() -> WorkspaceController -> WorkspaceAgent/WorkspaceTool -> WorkspaceAgentWindow` | v1b.5 用户选择工作区后,扫描索引、搜索文件/文本、读取文本文件、生成本地摘要,AI 可用时补简短分析,并支持打开普通文件、打开文件夹、显示所在位置、独立 Agent 窗口、菜单入口和全局快捷键 | 工作区外路径、系统目录、符号链接、危险后缀、敏感文件、命令执行均拒绝;打开文件前确认;AI 忙时不阻塞本地结果 | 路径归一化、符号链接逃逸、敏感文件判断、意图误判、全局快捷键冲突是核心风险;写操作仍未进入当前阶段 | v1a.1/v1b/v1b.5 独立窗口化已落地;低风险写操作计划留到后续 |
|
|
||||||
| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 |
|
| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | 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 或构建命令 |
|
||||||
| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;性能文档覆盖静置、AI、提醒、天气、联网模式、文件操作 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 |
|
|
||||||
|
|||||||
+5
-2
@@ -9,7 +9,7 @@
|
|||||||
| `QtDesktopPet_后续功能规划与结构审查.md` | 当前功能状态、下一阶段路线和结构边界 |
|
| `QtDesktopPet_后续功能规划与结构审查.md` | 当前功能状态、下一阶段路线和结构边界 |
|
||||||
| `QtDesktopPet_测试清单与验收标准.md` | 功能测试清单、回归验收标准和静态推演记录 |
|
| `QtDesktopPet_测试清单与验收标准.md` | 功能测试清单、回归验收标准和静态推演记录 |
|
||||||
| `performance_stability_check.md` | 性能采样、稳定性检查和发布验证入口 |
|
| `performance_stability_check.md` | 性能采样、稳定性检查和发布验证入口 |
|
||||||
| `agent/DesktopPet_本地工作区Agent_施工计划.md` | 本地工作区 Agent 的正式施工计划 |
|
| `test-records/` | 每次回归、性能和候选发布的长期测试记录 |
|
||||||
|
|
||||||
## 归档文档
|
## 归档文档
|
||||||
|
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
| `archive/implementation_plan.md` | 早期分阶段实施计划,当前大部分阶段已完成或被后续模块替代 |
|
| `archive/implementation_plan.md` | 早期分阶段实施计划,当前大部分阶段已完成或被后续模块替代 |
|
||||||
| `archive/Qt_DesktopPet_开发文档.md` | 初版开发总纲,当前产品能力已超出第一版范围 |
|
| `archive/Qt_DesktopPet_开发文档.md` | 初版开发总纲,当前产品能力已超出第一版范围 |
|
||||||
| `archive/QtDesktopPet_后续功能规划与结构审查_旧版.md` | 旧后续规划过长,包含大量已完成阶段和旧文件操作路线 |
|
| `archive/QtDesktopPet_后续功能规划与结构审查_旧版.md` | 旧后续规划过长,包含大量已完成阶段和旧文件操作路线 |
|
||||||
| `archive/DesktopPet 本地工作区 Agent 开发文档_草案.md` | 本地工作区 Agent 初稿,已整理为正式施工计划 |
|
| `archive/DesktopPet 本地工作区 Agent 开发文档_草案.md` | 已终止的本地工作区 Agent 初稿 |
|
||||||
|
| `archive/DesktopPet_本地工作区Agent_施工计划_已终止.md` | 工作区 Agent 路线已从当前产品移除 |
|
||||||
|
| `archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md` | 独立 Agent 窗口路线已从当前产品移除 |
|
||||||
|
|
||||||
## 维护规则
|
## 维护规则
|
||||||
|
|
||||||
@@ -28,4 +30,5 @@
|
|||||||
- 功能完成后,同步更新 README、测试清单和性能检查项。
|
- 功能完成后,同步更新 README、测试清单和性能检查项。
|
||||||
- 历史文档不再直接修改,除非补充归档说明。
|
- 历史文档不再直接修改,除非补充归档说明。
|
||||||
- GitHub 公开导出不包含 `docs/`;开发仓库保留完整文档。
|
- GitHub 公开导出不包含 `docs/`;开发仓库保留完整文档。
|
||||||
|
- 内部自动化测试位于 `internal/qa/`,只进入开发仓库,不进入 GitHub 公开导出和发布包。
|
||||||
- 在构建卡住问题解决前,文档中的验证步骤仍以用户手动构建为准,不由 Codex 调用 CMake 或构建命令。
|
- 在构建卡住问题解决前,文档中的验证步骤仍以用户手动构建为准,不由 Codex 调用 CMake 或构建命令。
|
||||||
|
|||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# DesktopPet 本地工作区 Agent 施工计划
|
# DesktopPet 本地工作区 Agent 施工计划
|
||||||
|
|
||||||
|
> 归档说明:该路线已于 2026-07-16 从 QtDesktopPet 当前产品方向中移除,本文仅保留历史设计,不再作为施工依据。
|
||||||
|
|
||||||
## 1. 定位
|
## 1. 定位
|
||||||
|
|
||||||
本地工作区 Agent 是下一阶段核心功能。它不是继续维护旧版“文件操作 v1”,而是新增一个独立的工作区系统:
|
本地工作区 Agent 是下一阶段核心功能。它不是继续维护旧版“文件操作 v1”,而是新增一个独立的工作区系统:
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# QtDesktopPet 本地工作区 Agent 独立窗口化施工文档
|
# QtDesktopPet 本地工作区 Agent 独立窗口化施工文档
|
||||||
|
|
||||||
|
> 归档说明:该路线已于 2026-07-16 从 QtDesktopPet 当前产品方向中移除,本文仅保留历史设计,不再作为施工依据。
|
||||||
|
|
||||||
## 0. 本次施工目标
|
## 0. 本次施工目标
|
||||||
|
|
||||||
当前项目已经有“本地工作区 Agent”面板雏形,能够显示:
|
当前项目已经有“本地工作区 Agent”面板雏形,能够显示:
|
||||||
@@ -22,13 +22,13 @@
|
|||||||
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条:
|
默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1
|
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m
|
||||||
```
|
```
|
||||||
|
|
||||||
指定 PID 采样:
|
指定 PID 采样:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 12345 -DurationSeconds 600
|
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Pid 12345 -Scenario idle-10m -DurationSeconds 600
|
||||||
```
|
```
|
||||||
|
|
||||||
输出目录默认是:
|
输出目录默认是:
|
||||||
@@ -37,28 +37,34 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 1
|
|||||||
reports/perf/
|
reports/perf/
|
||||||
```
|
```
|
||||||
|
|
||||||
`reports/perf/` 已加入 `.gitignore`。需要沉淀结论时,只把摘要写入本文档,不提交原始采样 CSV。
|
`reports/perf/` 已加入 `.gitignore`。原始 CSV 不提交;使用报告脚本生成统计摘要,审核并脱敏后写入对应的 `docs/test-records/` 记录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/New-PerformanceReport.ps1 `
|
||||||
|
-InputPath reports/perf/<sample>.csv -Scenario idle-5m
|
||||||
|
```
|
||||||
|
|
||||||
|
默认只强制判定进程意外退出和无响应。CPU、内存、句柄和线程阈值保存在 `internal/qa/config/performance-thresholds.json`,取得稳定 Release 基线后再按场景填写。
|
||||||
|
|
||||||
|
下表中的 `perf_sample.ps1` 均指 `internal/qa/scripts/perf_sample.ps1`。
|
||||||
|
|
||||||
## 检查项
|
## 检查项
|
||||||
|
|
||||||
| 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 |
|
| 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| 启动后静置 | 启动程序后不操作 5 分钟 | `tools/perf_sample.ps1` 默认参数 | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
|
| 启动后静置 | 启动程序后不操作 5 分钟 | `perf_sample.ps1 -Scenario idle-5m` | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO |
|
||||||
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `tools/perf_sample.ps1 -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
|
| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `perf_sample.ps1 -Scenario idle-10m -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO |
|
||||||
| 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO |
|
| 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO |
|
||||||
| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `tools/perf_sample.ps1 -DurationSeconds 300` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO |
|
| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `perf_sample.ps1 -Scenario show-hide` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO |
|
||||||
| 重复启动 / 单实例 | 已运行时连续双击 exe 多次;隐藏到托盘后再双击 exe | 任务管理器或 `Get-Process QtDesktopPet` | 始终只有一个进程;隐藏状态可被重复启动唤醒 | TODO | TODO | 检查 QLocalServer 激活路径 |
|
| 重复启动 / 单实例 | 已运行时连续双击 exe 多次;隐藏到托盘后再双击 exe | 任务管理器或 `Get-Process QtDesktopPet` | 始终只有一个进程;隐藏状态可被重复启动唤醒 | TODO | TODO | 检查 QLocalServer 激活路径 |
|
||||||
| 设置页居中 / 置前 | 从托盘打开设置页;多屏环境重复打开;设置页已打开时再次启动 exe | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 |
|
| 设置页居中 / 置前 | 从托盘打开设置页;多屏环境重复打开;设置页已打开时再次启动 exe | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 |
|
||||||
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `tools/perf_sample.ps1 -DurationSeconds 300` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
|
| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `perf_sample.ps1 -Scenario state-switch` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 |
|
||||||
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `perf_sample.ps1 -Scenario prewarm-unload -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 |
|
||||||
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
|
| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `perf_sample.ps1 -Scenario scale-topmost` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO |
|
||||||
| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
|
| AI 对话 | 连续发送 20 轮短消息 | `perf_sample.ps1 -Scenario ai-20-turns -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 |
|
||||||
| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `tools/perf_sample.ps1 -DurationSeconds 300` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 |
|
| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `perf_sample.ps1 -Scenario reminder-trigger` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 |
|
||||||
| 天气查询 | 连续查询当前/明天/未来三天天气 | `tools/perf_sample.ps1 -DurationSeconds 300` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 |
|
| 天气查询 | 连续查询当前/明天/未来三天天气 | `perf_sample.ps1 -Scenario weather-queries` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 |
|
||||||
| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `tools/perf_sample.ps1 -DurationSeconds 300` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 |
|
| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `perf_sample.ps1 -Scenario web-queries` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 |
|
||||||
| 应用启动 | 登记一个测试 exe,聊天触发启动并取消/确认各一次 | 手工观察 + 采样 | 启动前始终二次确认;取消时不启动;确认后 UI 可继续响应;不执行脚本或聊天参数 | TODO | TODO | 使用无副作用测试程序,例如记事本或临时测试 exe |
|
|
||||||
| 本地文件操作 | 读取文本、列目录、复制、备份、重命名 | 手工观察 + 采样 | 写操作有确认;不覆盖、不删除、不访问系统目录;操作后 UI 可继续响应 | TODO | TODO | 使用临时测试目录,不操作真实重要文件 |
|
|
||||||
| 本地工作区 Agent v1b.5 | 选择临时项目工作区,扫描、搜索文件、搜索文本、读取文件、总结文件、打开普通文件、显示所在位置,并使用独立 Agent 窗口、右键/托盘入口和 `Alt+A` 快捷键操作选中结果 | 手工观察 + `tools/perf_sample.ps1 -DurationSeconds 600` | 扫描和搜索结果数量受限制;独立窗口刷新不阻塞 UI;快捷键注册失败有提示且菜单入口可用;打开文件前二次确认;系统目录、符号链接、危险后缀、敏感文件和命令执行均拒绝;日志不记录敏感文件内容;内存无持续异常增长 | TODO | TODO | 不做创建、修改、移动、删除;使用临时测试仓库,不操作真实重要项目 |
|
|
||||||
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
|
| 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 |
|
||||||
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
|
| 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 |
|
||||||
|
|
||||||
@@ -81,7 +87,7 @@ reports/perf/
|
|||||||
```text
|
```text
|
||||||
1. 后台线程预热
|
1. 后台线程预热
|
||||||
2. 单帧级缓存
|
2. 单帧级缓存
|
||||||
3. 自动化长期压测记录
|
3. 经 Release 基线校准的自动性能阈值
|
||||||
```
|
```
|
||||||
|
|
||||||
## 发布包说明
|
## 发布包说明
|
||||||
@@ -91,6 +97,7 @@ reports/perf/
|
|||||||
```text
|
```text
|
||||||
tools/
|
tools/
|
||||||
docs/
|
docs/
|
||||||
|
internal/
|
||||||
reports/
|
reports/
|
||||||
build/
|
build/
|
||||||
dist/
|
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,
|
[int]$IntervalSeconds = 5,
|
||||||
[ValidateRange(1, 604800)]
|
[ValidateRange(1, 604800)]
|
||||||
[int]$DurationSeconds = 300,
|
[int]$DurationSeconds = 300,
|
||||||
|
[string]$Scenario = "unspecified",
|
||||||
[string]$OutputPath = "reports/perf"
|
[string]$OutputPath = "reports/perf"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,39 +36,37 @@ function Resolve-TargetProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Read-ProcessSnapshot {
|
function Read-ProcessSnapshot {
|
||||||
param(
|
param([System.Diagnostics.Process]$Process)
|
||||||
[System.Diagnostics.Process]$Process
|
|
||||||
)
|
|
||||||
|
|
||||||
$path = ""
|
$path = ""
|
||||||
try {
|
try { $path = $Process.Path } catch { $path = "" }
|
||||||
$path = $Process.Path
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
$path = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
$responding = ""
|
$responding = ""
|
||||||
try {
|
try { $responding = $Process.Responding } catch { $responding = "" }
|
||||||
$responding = $Process.Responding
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
$responding = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
Pid = $Process.Id
|
Pid = $Process.Id
|
||||||
CpuSeconds = [double]($Process.CPU)
|
CpuSeconds = [double]($Process.CPU)
|
||||||
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
|
WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2)
|
||||||
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
|
PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2)
|
||||||
HandleCount = $Process.HandleCount
|
HandleCount = $Process.HandleCount
|
||||||
ThreadCount = $Process.Threads.Count
|
ThreadCount = $Process.Threads.Count
|
||||||
Responding = $responding
|
Responding = $responding
|
||||||
Path = $path
|
Path = $path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Resolve-OutputDirectory {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||||
|
return [System.IO.Path]::GetFullPath($Path)
|
||||||
|
}
|
||||||
|
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\.."))
|
||||||
|
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
|
||||||
|
}
|
||||||
|
|
||||||
function New-OutputFilePath {
|
function New-OutputFilePath {
|
||||||
param(
|
param(
|
||||||
[string]$Directory,
|
[string]$Directory,
|
||||||
@@ -87,37 +86,35 @@ $target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId
|
|||||||
if ($null -eq $target) {
|
if ($null -eq $target) {
|
||||||
if ($ProcessId -gt 0) {
|
if ($ProcessId -gt 0) {
|
||||||
Write-Error "Process with PID $ProcessId was not found."
|
Write-Error "Process with PID $ProcessId was not found."
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
|
Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid."
|
||||||
}
|
}
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$logicalProcessorCount = [Environment]::ProcessorCount
|
$logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount)
|
||||||
if ($logicalProcessorCount -lt 1) {
|
$outputDirectory = Resolve-OutputDirectory -Path $OutputPath
|
||||||
$logicalProcessorCount = 1
|
$outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)"
|
||||||
}
|
|
||||||
|
|
||||||
$outputFile = New-OutputFilePath -Directory $OutputPath -Name $target.ProcessName
|
|
||||||
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
|
$sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds))
|
||||||
|
|
||||||
Write-Host "Sampling PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
Write-Host "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds."
|
||||||
Write-Host "Output: $outputFile"
|
Write-Host "Output: $outputFile"
|
||||||
|
|
||||||
$previousSnapshot = Read-ProcessSnapshot -Process $target
|
$previousSnapshot = Read-ProcessSnapshot -Process $target
|
||||||
$previousTime = Get-Date
|
$previousTime = Get-Date
|
||||||
$firstRow = [pscustomobject]@{
|
$firstRow = [pscustomobject]@{
|
||||||
TimestampUtc = $previousSnapshot.TimestampUtc
|
Scenario = $Scenario
|
||||||
Pid = $previousSnapshot.Pid
|
SampleIndex = 0
|
||||||
CpuPercent = 0
|
TimestampUtc = $previousSnapshot.TimestampUtc
|
||||||
WorkingSetMB = $previousSnapshot.WorkingSetMB
|
Pid = $previousSnapshot.Pid
|
||||||
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
|
CpuPercent = 0
|
||||||
HandleCount = $previousSnapshot.HandleCount
|
WorkingSetMB = $previousSnapshot.WorkingSetMB
|
||||||
ThreadCount = $previousSnapshot.ThreadCount
|
PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB
|
||||||
Responding = $previousSnapshot.Responding
|
HandleCount = $previousSnapshot.HandleCount
|
||||||
Path = $previousSnapshot.Path
|
ThreadCount = $previousSnapshot.ThreadCount
|
||||||
Status = "running"
|
Responding = $previousSnapshot.Responding
|
||||||
|
Path = $previousSnapshot.Path
|
||||||
|
Status = "running"
|
||||||
}
|
}
|
||||||
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
|
$firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8
|
||||||
|
|
||||||
@@ -127,16 +124,18 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
|||||||
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
|
$currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue
|
||||||
if ($null -eq $currentProcess) {
|
if ($null -eq $currentProcess) {
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
Scenario = $Scenario
|
||||||
Pid = $target.Id
|
SampleIndex = $index
|
||||||
CpuPercent = ""
|
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
WorkingSetMB = ""
|
Pid = $target.Id
|
||||||
PrivateMemoryMB = ""
|
CpuPercent = ""
|
||||||
HandleCount = ""
|
WorkingSetMB = ""
|
||||||
ThreadCount = ""
|
PrivateMemoryMB = ""
|
||||||
Responding = ""
|
HandleCount = ""
|
||||||
Path = $previousSnapshot.Path
|
ThreadCount = ""
|
||||||
Status = "exited"
|
Responding = ""
|
||||||
|
Path = $previousSnapshot.Path
|
||||||
|
Status = "exited"
|
||||||
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
||||||
|
|
||||||
Write-Warning "Process $($target.Id) exited. Sampling stopped."
|
Write-Warning "Process $($target.Id) exited. Sampling stopped."
|
||||||
@@ -147,25 +146,25 @@ for ($index = 1; $index -le $sampleCount; $index++) {
|
|||||||
$currentTime = Get-Date
|
$currentTime = Get-Date
|
||||||
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
|
$elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds)
|
||||||
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
|
$cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount
|
||||||
if ($cpuPercent -lt 0) {
|
if ($cpuPercent -lt 0) { $cpuPercent = 0 }
|
||||||
$cpuPercent = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
TimestampUtc = $currentSnapshot.TimestampUtc
|
Scenario = $Scenario
|
||||||
Pid = $currentSnapshot.Pid
|
SampleIndex = $index
|
||||||
CpuPercent = [math]::Round($cpuPercent, 2)
|
TimestampUtc = $currentSnapshot.TimestampUtc
|
||||||
WorkingSetMB = $currentSnapshot.WorkingSetMB
|
Pid = $currentSnapshot.Pid
|
||||||
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
|
CpuPercent = [math]::Round($cpuPercent, 2)
|
||||||
HandleCount = $currentSnapshot.HandleCount
|
WorkingSetMB = $currentSnapshot.WorkingSetMB
|
||||||
ThreadCount = $currentSnapshot.ThreadCount
|
PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB
|
||||||
Responding = $currentSnapshot.Responding
|
HandleCount = $currentSnapshot.HandleCount
|
||||||
Path = $currentSnapshot.Path
|
ThreadCount = $currentSnapshot.ThreadCount
|
||||||
Status = "running"
|
Responding = $currentSnapshot.Responding
|
||||||
|
Path = $currentSnapshot.Path
|
||||||
|
Status = "running"
|
||||||
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
} | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append
|
||||||
|
|
||||||
$previousSnapshot = $currentSnapshot
|
$previousSnapshot = $currentSnapshot
|
||||||
$previousTime = $currentTime
|
$previousTime = $currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Sampling finished."
|
Write-Host "Sampling finished: $outputFile"
|
||||||
@@ -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"
|
"3. 查询天气:\n"
|
||||||
" - 例:“西安天气怎么样”“明天北京天气”“未来三天上海天气”。\n"
|
" - 例:“西安天气怎么样”“明天北京天气”“未来三天上海天气”。\n"
|
||||||
" - 建议用户使用市级城市名;区县、乡镇、街道不保证精确。\n"
|
" - 建议用户使用市级城市名;区县、乡镇、街道不保证精确。\n"
|
||||||
"4. 本地文件操作:\n"
|
"4. 联网模式:\n"
|
||||||
" - 例:“读取文件”“列出文件夹”“复制文件”“备份文件”“重命名文件”。\n"
|
|
||||||
" - 文件和文件夹必须由用户在系统选择框中主动选择。\n"
|
|
||||||
" - 不支持删除、覆盖、移动、执行脚本、运行命令或 zip 打包。\n"
|
|
||||||
"5. 启动本地应用:\n"
|
|
||||||
" - 例:“打开 Codex”“启动酷狗音乐”“打开 VSCode”。\n"
|
|
||||||
" - 启动前需要用户确认;不会执行聊天文本里的命令或脚本。\n"
|
|
||||||
"6. 联网模式:\n"
|
|
||||||
" - 用户需要打开输入框里的“联网”开关。\n"
|
" - 用户需要打开输入框里的“联网”开关。\n"
|
||||||
" - 只有当前 AI 配置支持原生联网时才可用,例如 OpenAI 官方联网能力或 Gemini Google Search grounding。\n"
|
" - 只有当前 AI 配置支持原生联网时才可用,例如 OpenAI 官方联网能力或 Gemini Google Search grounding。\n"
|
||||||
" - DeepSeek 官方 API、第三方 OpenAI-Compatible API 通常不具备可确认的原生联网能力;遇到这类配置时,应明确提醒用户当前配置无法联网,不要假装已经搜索。\n"
|
" - DeepSeek 官方 API、第三方 OpenAI-Compatible API 通常不具备可确认的原生联网能力;遇到这类配置时,应明确提醒用户当前配置无法联网,不要假装已经搜索。\n"
|
||||||
@@ -31,10 +24,10 @@ ConversationManager::ConversationManager()
|
|||||||
"回答规则:\n"
|
"回答规则:\n"
|
||||||
"- 如果用户只是闲聊,直接自然回复。\n"
|
"- 如果用户只是闲聊,直接自然回复。\n"
|
||||||
"- 如果用户问“你能做什么”,用简短列表说明上述功能和示例。\n"
|
"- 如果用户问“你能做什么”,用简短列表说明上述功能和示例。\n"
|
||||||
"- 如果用户要做提醒、天气、文件操作或打开应用,可以直接提示用户按示例输入。\n"
|
"- 如果用户要创建提醒或查询天气,可以直接提示用户按示例输入。\n"
|
||||||
"- 如果用户请求危险操作,例如删除文件、覆盖文件、执行命令、运行脚本、提权操作,应明确拒绝,并给出安全替代方案。\n"
|
"- 当前应用不能读取、搜索、创建、修改或删除本地文件,不能启动本地应用,也不能执行命令或脚本;遇到这类请求要明确说明能力边界。\n"
|
||||||
"- 如果用户询问最新信息但没有开启联网,提醒用户打开“联网”开关;如果当前 AI 配置不支持联网,说明无法使用联网模式。\n"
|
"- 如果用户询问最新信息但没有开启联网,提醒用户打开“联网”开关;如果当前 AI 配置不支持联网,说明无法使用联网模式。\n"
|
||||||
"- 不要编造已经执行了某个本地操作;只有系统明确完成后,才能说“已完成”。"))
|
"- 不要声称已经访问本地文件、启动应用或执行系统操作,也不要编造任何未发生的操作结果。"))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,52 +132,6 @@ void ConversationManager::appendExternalExchange(const QString &userMessage, con
|
|||||||
appendExchange(user, assistant);
|
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)
|
void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback)
|
||||||
{
|
{
|
||||||
const QString content = message.trimmed();
|
const QString content = message.trimmed();
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public:
|
|||||||
bool setProvider(std::unique_ptr<LLMProvider> provider);
|
bool setProvider(std::unique_ptr<LLMProvider> provider);
|
||||||
ChatRequest buildRequestForUserMessage(const QString &message) const;
|
ChatRequest buildRequestForUserMessage(const QString &message) const;
|
||||||
void appendExternalExchange(const QString &userMessage, const QString &assistantMessage);
|
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 sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback);
|
||||||
void cancel();
|
void cancel();
|
||||||
void clear();
|
void clear();
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ QString userIntentTypeName(UserIntentType type)
|
|||||||
return QStringLiteral("Reminder");
|
return QStringLiteral("Reminder");
|
||||||
case UserIntentType::Weather:
|
case UserIntentType::Weather:
|
||||||
return QStringLiteral("Weather");
|
return QStringLiteral("Weather");
|
||||||
case UserIntentType::Workspace:
|
|
||||||
return QStringLiteral("Workspace");
|
|
||||||
case UserIntentType::FileOperation:
|
|
||||||
return QStringLiteral("FileOperation");
|
|
||||||
case UserIntentType::LaunchApp:
|
|
||||||
return QStringLiteral("LaunchApp");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return QStringLiteral("Unknown");
|
return QStringLiteral("Unknown");
|
||||||
@@ -24,56 +18,15 @@ QString userIntentTypeName(UserIntentType type)
|
|||||||
CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
|
CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const
|
||||||
{
|
{
|
||||||
const UserIntent intent = m_intentRouter.route(text);
|
const UserIntent intent = m_intentRouter.route(text);
|
||||||
if (intent.type == UserIntentType::Chat)
|
switch (intent.type)
|
||||||
{
|
{
|
||||||
|
case UserIntentType::Chat:
|
||||||
return {CommandDispatchAction::Chat, intent, intent.text};
|
return {CommandDispatchAction::Chat, intent, intent.text};
|
||||||
}
|
case UserIntentType::Reminder:
|
||||||
|
|
||||||
if (intent.type == UserIntentType::Reminder)
|
|
||||||
{
|
|
||||||
return {CommandDispatchAction::Reminder, intent, intent.text};
|
return {CommandDispatchAction::Reminder, intent, intent.text};
|
||||||
}
|
case UserIntentType::Weather:
|
||||||
|
|
||||||
if (intent.type == UserIntentType::Weather)
|
|
||||||
{
|
|
||||||
return {CommandDispatchAction::Weather, intent, intent.text};
|
return {CommandDispatchAction::Weather, intent, intent.text};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent.type == UserIntentType::Workspace)
|
return {CommandDispatchAction::Chat, intent, intent.text};
|
||||||
{
|
|
||||||
return {CommandDispatchAction::Workspace, 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::Workspace:
|
|
||||||
return QStringLiteral("本地工作区请求暂时无法处理,请稍后再试。");
|
|
||||||
case UserIntentType::FileOperation:
|
|
||||||
return QStringLiteral("本地文件操作请求暂时无法处理,请稍后再试。");
|
|
||||||
case UserIntentType::LaunchApp:
|
|
||||||
return QStringLiteral("应用启动请求暂时无法处理,请稍后再试。");
|
|
||||||
case UserIntentType::Chat:
|
|
||||||
return QString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return QStringLiteral("该请求暂时无法处理,请稍后再试。");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ enum class CommandDispatchAction
|
|||||||
Chat,
|
Chat,
|
||||||
Reminder,
|
Reminder,
|
||||||
Weather,
|
Weather,
|
||||||
Workspace,
|
|
||||||
FileOperation,
|
|
||||||
LaunchApp,
|
|
||||||
UnsupportedTool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct CommandDispatchResult
|
struct CommandDispatchResult
|
||||||
@@ -28,7 +24,5 @@ public:
|
|||||||
CommandDispatchResult dispatch(const QString &text) const;
|
CommandDispatchResult dispatch(const QString &text) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QString unsupportedToolMessage(UserIntentType type) const;
|
|
||||||
|
|
||||||
IntentRouter m_intentRouter;
|
IntentRouter m_intentRouter;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#include "IntentRouter.h"
|
#include "IntentRouter.h"
|
||||||
|
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -14,120 +13,9 @@ bool containsAny(const QString &text, const QStringList &keywords)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool containsOrdinalReference(const QString &text)
|
|
||||||
{
|
|
||||||
return containsAny(text, {
|
|
||||||
QStringLiteral("第一个"),
|
|
||||||
QStringLiteral("第一项"),
|
|
||||||
QStringLiteral("第1个"),
|
|
||||||
QStringLiteral("第1项"),
|
|
||||||
QStringLiteral("第二个"),
|
|
||||||
QStringLiteral("第二项"),
|
|
||||||
QStringLiteral("第2个"),
|
|
||||||
QStringLiteral("第2项"),
|
|
||||||
QStringLiteral("第三个"),
|
|
||||||
QStringLiteral("第三项"),
|
|
||||||
QStringLiteral("第3个"),
|
|
||||||
QStringLiteral("第3项"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool containsWorkspaceFileAnalysisVerb(const QString &text)
|
|
||||||
{
|
|
||||||
return containsAny(text, {
|
|
||||||
QStringLiteral("分析"),
|
|
||||||
QStringLiteral("总结"),
|
|
||||||
QStringLiteral("解释"),
|
|
||||||
QStringLiteral("解读"),
|
|
||||||
QStringLiteral("说明"),
|
|
||||||
QStringLiteral("概括"),
|
|
||||||
QStringLiteral("梳理"),
|
|
||||||
QStringLiteral("看看"),
|
|
||||||
QStringLiteral("看下"),
|
|
||||||
QStringLiteral("看一下"),
|
|
||||||
QStringLiteral("讲讲"),
|
|
||||||
QStringLiteral("阅读"),
|
|
||||||
QStringLiteral("读懂"),
|
|
||||||
QStringLiteral("审查"),
|
|
||||||
QStringLiteral("检查"),
|
|
||||||
QStringLiteral("review"),
|
|
||||||
QStringLiteral("干嘛"),
|
|
||||||
QStringLiteral("作用"),
|
|
||||||
QStringLiteral("用途"),
|
|
||||||
QStringLiteral("做什么"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool containsWriteOrExecutionVerb(const QString &text)
|
|
||||||
{
|
|
||||||
return containsAny(text, {
|
|
||||||
QStringLiteral("创建"),
|
|
||||||
QStringLiteral("新建"),
|
|
||||||
QStringLiteral("写入"),
|
|
||||||
QStringLiteral("修改"),
|
|
||||||
QStringLiteral("修复"),
|
|
||||||
QStringLiteral("优化"),
|
|
||||||
QStringLiteral("覆盖"),
|
|
||||||
QStringLiteral("删除"),
|
|
||||||
QStringLiteral("移动"),
|
|
||||||
QStringLiteral("重命名"),
|
|
||||||
QStringLiteral("执行"),
|
|
||||||
QStringLiteral("运行"),
|
|
||||||
QStringLiteral("命令"),
|
|
||||||
QStringLiteral("脚本"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool containsWorkspaceTextSearchPattern(const QString &text)
|
|
||||||
{
|
|
||||||
static const QStringList targetContexts = {
|
|
||||||
QStringLiteral("文件"),
|
|
||||||
QStringLiteral("代码"),
|
|
||||||
QStringLiteral("源码"),
|
|
||||||
QStringLiteral("项目"),
|
|
||||||
QStringLiteral("工作区"),
|
|
||||||
};
|
|
||||||
if (!containsAny(text, targetContexts))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const QStringList strongMarkers = {
|
|
||||||
QStringLiteral("包含"),
|
|
||||||
QStringLiteral("带有"),
|
|
||||||
QStringLiteral("含"),
|
|
||||||
};
|
|
||||||
for (const QString &marker : strongMarkers)
|
|
||||||
{
|
|
||||||
const int markerIndex = text.indexOf(marker, 0, Qt::CaseInsensitive);
|
|
||||||
if (markerIndex < 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const QString &context : targetContexts)
|
|
||||||
{
|
|
||||||
const int contextIndex = text.indexOf(context, 0, Qt::CaseInsensitive);
|
|
||||||
if (contextIndex > markerIndex)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool hasSearchVerb = containsAny(text, {
|
|
||||||
QStringLiteral("搜索"),
|
|
||||||
QStringLiteral("查找"),
|
|
||||||
QStringLiteral("找一下"),
|
|
||||||
QStringLiteral("找找"),
|
|
||||||
});
|
|
||||||
return hasSearchVerb && text.contains(QStringLiteral("有"), Qt::CaseInsensitive);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isReminderIntent(const QString &text)
|
bool isReminderIntent(const QString &text)
|
||||||
{
|
{
|
||||||
static const QStringList keywords = {
|
static const QStringList keywords = {
|
||||||
@@ -140,36 +28,6 @@ bool isReminderIntent(const QString &text)
|
|||||||
return containsAny(text, keywords);
|
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("覆盖"),
|
|
||||||
QStringLiteral("复制"),
|
|
||||||
QStringLiteral("备份"),
|
|
||||||
QStringLiteral("重命名"),
|
|
||||||
QStringLiteral("删除"),
|
|
||||||
QStringLiteral("移动"),
|
|
||||||
QStringLiteral("读取"),
|
|
||||||
QStringLiteral(".txt"),
|
|
||||||
QStringLiteral(".md"),
|
|
||||||
QStringLiteral("日志"),
|
|
||||||
};
|
|
||||||
return containsAny(text, keywords);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isWeatherIntent(const QString &text)
|
bool isWeatherIntent(const QString &text)
|
||||||
{
|
{
|
||||||
static const QStringList keywords = {
|
static const QStringList keywords = {
|
||||||
@@ -192,241 +50,18 @@ bool isWeatherIntent(const QString &text)
|
|||||||
};
|
};
|
||||||
return containsAny(text, keywords);
|
return containsAny(text, keywords);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isWorkspaceIntent(const QString &text)
|
|
||||||
{
|
|
||||||
static const QStringList directKeywords = {
|
|
||||||
QStringLiteral("打开工作区"),
|
|
||||||
QStringLiteral("选择工作区"),
|
|
||||||
QStringLiteral("当前工作区"),
|
|
||||||
QStringLiteral("扫描工作区"),
|
|
||||||
QStringLiteral("重新扫描"),
|
|
||||||
QStringLiteral("分析这个项目"),
|
|
||||||
QStringLiteral("分析当前项目"),
|
|
||||||
QStringLiteral("项目结构"),
|
|
||||||
QStringLiteral("工作区结构"),
|
|
||||||
QStringLiteral("总结这个项目"),
|
|
||||||
QStringLiteral("分析这个文件"),
|
|
||||||
QStringLiteral("分析文件"),
|
|
||||||
QStringLiteral("总结这个文件"),
|
|
||||||
QStringLiteral("总结文件"),
|
|
||||||
QStringLiteral("解释这个文件"),
|
|
||||||
QStringLiteral("解读这个文件"),
|
|
||||||
QStringLiteral("看看这个文件"),
|
|
||||||
QStringLiteral("看下这个文件"),
|
|
||||||
QStringLiteral("看一下这个文件"),
|
|
||||||
QStringLiteral("review这个文件"),
|
|
||||||
QStringLiteral("review 这个文件"),
|
|
||||||
QStringLiteral("打开所在文件夹"),
|
|
||||||
QStringLiteral("显示所在位置"),
|
|
||||||
QStringLiteral("显示文件位置"),
|
|
||||||
};
|
|
||||||
if (containsAny(text, directKeywords))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.contains(QStringLiteral("总结第"), Qt::CaseInsensitive)
|
|
||||||
|| text.contains(QStringLiteral("分析第"), Qt::CaseInsensitive)
|
|
||||||
|| text.contains(QStringLiteral("解释第"), Qt::CaseInsensitive)
|
|
||||||
|| text.contains(QStringLiteral("解读第"), Qt::CaseInsensitive)
|
|
||||||
|| text.contains(QStringLiteral("review第"), Qt::CaseInsensitive)
|
|
||||||
|| ((text.contains(QStringLiteral("打开"), Qt::CaseInsensitive)
|
|
||||||
|| text.contains(QStringLiteral("显示"), Qt::CaseInsensitive))
|
|
||||||
&& containsOrdinalReference(text)))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const QStringList workspaceMarkers = {
|
|
||||||
QStringLiteral("工作区"),
|
|
||||||
QStringLiteral("这个项目"),
|
|
||||||
QStringLiteral("当前项目"),
|
|
||||||
QStringLiteral("项目里"),
|
|
||||||
QStringLiteral("代码"),
|
|
||||||
QStringLiteral("代码里"),
|
|
||||||
QStringLiteral("源码"),
|
|
||||||
QStringLiteral("源码里"),
|
|
||||||
QStringLiteral("仓库里"),
|
|
||||||
};
|
|
||||||
static const QStringList workspaceActions = {
|
|
||||||
QStringLiteral("搜索"),
|
|
||||||
QStringLiteral("查找"),
|
|
||||||
QStringLiteral("找一下"),
|
|
||||||
QStringLiteral("读取"),
|
|
||||||
QStringLiteral("读一下"),
|
|
||||||
QStringLiteral("分析"),
|
|
||||||
QStringLiteral("总结"),
|
|
||||||
QStringLiteral("列出"),
|
|
||||||
QStringLiteral("打开"),
|
|
||||||
QStringLiteral("显示"),
|
|
||||||
QStringLiteral("创建"),
|
|
||||||
QStringLiteral("新建"),
|
|
||||||
QStringLiteral("写入"),
|
|
||||||
QStringLiteral("修改"),
|
|
||||||
QStringLiteral("修复"),
|
|
||||||
QStringLiteral("优化"),
|
|
||||||
QStringLiteral("覆盖"),
|
|
||||||
QStringLiteral("删除"),
|
|
||||||
QStringLiteral("移动"),
|
|
||||||
QStringLiteral("重命名"),
|
|
||||||
QStringLiteral("所在位置"),
|
|
||||||
QStringLiteral("所在文件夹"),
|
|
||||||
QStringLiteral("运行"),
|
|
||||||
QStringLiteral("执行"),
|
|
||||||
QStringLiteral("命令"),
|
|
||||||
QStringLiteral("脚本"),
|
|
||||||
QStringLiteral("安装依赖"),
|
|
||||||
QStringLiteral("git commit"),
|
|
||||||
};
|
|
||||||
if (containsAny(text, workspaceMarkers) && containsAny(text, workspaceActions))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const QStringList fileHints = {
|
|
||||||
QStringLiteral("README"),
|
|
||||||
QStringLiteral("CMakeLists"),
|
|
||||||
QStringLiteral("main.cpp"),
|
|
||||||
QStringLiteral(".cpp"),
|
|
||||||
QStringLiteral(".h"),
|
|
||||||
QStringLiteral(".hpp"),
|
|
||||||
QStringLiteral(".py"),
|
|
||||||
QStringLiteral(".js"),
|
|
||||||
QStringLiteral(".ts"),
|
|
||||||
QStringLiteral(".json"),
|
|
||||||
QStringLiteral(".cmake"),
|
|
||||||
QStringLiteral(".pro"),
|
|
||||||
QStringLiteral("docs"),
|
|
||||||
QStringLiteral("src"),
|
|
||||||
QStringLiteral("resources"),
|
|
||||||
};
|
|
||||||
if (containsAny(text, fileHints)
|
|
||||||
&& containsAny(text, {
|
|
||||||
QStringLiteral("搜索"),
|
|
||||||
QStringLiteral("查找"),
|
|
||||||
QStringLiteral("找一下"),
|
|
||||||
QStringLiteral("读取"),
|
|
||||||
QStringLiteral("读一下"),
|
|
||||||
QStringLiteral("阅读"),
|
|
||||||
QStringLiteral("读懂"),
|
|
||||||
QStringLiteral("总结"),
|
|
||||||
QStringLiteral("解释"),
|
|
||||||
QStringLiteral("解读"),
|
|
||||||
QStringLiteral("说明"),
|
|
||||||
QStringLiteral("概括"),
|
|
||||||
QStringLiteral("梳理"),
|
|
||||||
QStringLiteral("看看"),
|
|
||||||
QStringLiteral("看下"),
|
|
||||||
QStringLiteral("看一下"),
|
|
||||||
QStringLiteral("讲讲"),
|
|
||||||
QStringLiteral("审查"),
|
|
||||||
QStringLiteral("检查"),
|
|
||||||
QStringLiteral("review"),
|
|
||||||
QStringLiteral("干嘛"),
|
|
||||||
QStringLiteral("作用"),
|
|
||||||
QStringLiteral("用途"),
|
|
||||||
QStringLiteral("做什么"),
|
|
||||||
QStringLiteral("打开"),
|
|
||||||
QStringLiteral("显示"),
|
|
||||||
QStringLiteral("创建"),
|
|
||||||
QStringLiteral("新建"),
|
|
||||||
QStringLiteral("写入"),
|
|
||||||
QStringLiteral("修改"),
|
|
||||||
QStringLiteral("修复"),
|
|
||||||
QStringLiteral("优化"),
|
|
||||||
QStringLiteral("覆盖"),
|
|
||||||
QStringLiteral("删除"),
|
|
||||||
QStringLiteral("移动"),
|
|
||||||
QStringLiteral("重命名"),
|
|
||||||
QStringLiteral("所在位置"),
|
|
||||||
QStringLiteral("所在文件夹"),
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!containsWriteOrExecutionVerb(text)
|
|
||||||
&& containsWorkspaceFileAnalysisVerb(text)
|
|
||||||
&& containsAny(text, {QStringLiteral("文件"), QStringLiteral("当前文件"), QStringLiteral("这个文件"), QStringLiteral("刚才的文件"), QStringLiteral("最近读取的文件")}))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containsAny(text, {QStringLiteral("搜索包含"), QStringLiteral("查找包含"), QStringLiteral("找一下包含")})
|
|
||||||
&& containsAny(text, {QStringLiteral("文件"), QStringLiteral("代码")}))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containsWorkspaceTextSearchPattern(text))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
UserIntent IntentRouter::route(const QString &text) const
|
||||||
{
|
{
|
||||||
const QString trimmedText = text.trimmed();
|
const QString trimmedText = text.trimmed();
|
||||||
|
|
||||||
if (isReminderIntent(trimmedText))
|
if (isReminderIntent(trimmedText))
|
||||||
{
|
{
|
||||||
return {UserIntentType::Reminder, trimmedText};
|
return {UserIntentType::Reminder, trimmedText};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeatherIntent(trimmedText))
|
if (isWeatherIntent(trimmedText))
|
||||||
{
|
{
|
||||||
return {UserIntentType::Weather, trimmedText};
|
return {UserIntentType::Weather, trimmedText};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWorkspaceIntent(trimmedText))
|
|
||||||
{
|
|
||||||
return {UserIntentType::Workspace, trimmedText};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFileOperationIntent(trimmedText))
|
|
||||||
{
|
|
||||||
return {UserIntentType::FileOperation, trimmedText};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLaunchAppIntent(trimmedText))
|
|
||||||
{
|
|
||||||
return {UserIntentType::LaunchApp, trimmedText};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {UserIntentType::Chat, trimmedText};
|
return {UserIntentType::Chat, trimmedText};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ enum class UserIntentType
|
|||||||
Chat,
|
Chat,
|
||||||
Reminder,
|
Reminder,
|
||||||
Weather,
|
Weather,
|
||||||
Workspace,
|
|
||||||
FileOperation,
|
|
||||||
LaunchApp,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UserIntent
|
struct UserIntent
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include <QJsonValue>
|
#include <QJsonValue>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -535,18 +536,6 @@ QVector<CharacterPackageInfo> CharacterPackageRepository::availablePackages()
|
|||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList CharacterPackageRepository::availablePackageIds()
|
|
||||||
{
|
|
||||||
QStringList packageIds;
|
|
||||||
const QVector<CharacterPackageInfo> packages = availablePackages();
|
|
||||||
for (const CharacterPackageInfo &package : packages)
|
|
||||||
{
|
|
||||||
packageIds.append(package.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return packageIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath)
|
CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath)
|
||||||
{
|
{
|
||||||
CharacterPackageValidationResult result;
|
CharacterPackageValidationResult result;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
|
||||||
struct CharacterPackageInfo
|
struct CharacterPackageInfo
|
||||||
@@ -40,7 +39,6 @@ public:
|
|||||||
static bool hasBuiltInPackage(const QString &characterId);
|
static bool hasBuiltInPackage(const QString &characterId);
|
||||||
static bool hasUserPackage(const QString &characterId);
|
static bool hasUserPackage(const QString &characterId);
|
||||||
static QVector<CharacterPackageInfo> availablePackages();
|
static QVector<CharacterPackageInfo> availablePackages();
|
||||||
static QStringList availablePackageIds();
|
|
||||||
static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath);
|
static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath);
|
||||||
static bool validatePackageDirectory(
|
static bool validatePackageDirectory(
|
||||||
const QString &directoryPath,
|
const QString &directoryPath,
|
||||||
|
|||||||
@@ -79,11 +79,6 @@ bool FrameAnimator::isPlaying() const
|
|||||||
return m_timer.isActive() && m_clip != nullptr;
|
return m_timer.isActive() && m_clip != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FrameAnimator::isPaused() const
|
|
||||||
{
|
|
||||||
return m_paused && m_clip != nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString FrameAnimator::currentStateName() const
|
QString FrameAnimator::currentStateName() const
|
||||||
{
|
{
|
||||||
if (m_clip == nullptr)
|
if (m_clip == nullptr)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ public:
|
|||||||
void resume();
|
void resume();
|
||||||
void stop();
|
void stop();
|
||||||
bool isPlaying() const;
|
bool isPlaying() const;
|
||||||
bool isPaused() const;
|
|
||||||
QString currentStateName() const;
|
QString currentStateName() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -24,7 +24,4 @@ struct AppConfig
|
|||||||
int memoryHistoryMessageLimit = 200;
|
int memoryHistoryMessageLimit = 200;
|
||||||
bool saveConversationHistory = false;
|
bool saveConversationHistory = false;
|
||||||
int savedHistoryMessageLimit = 500;
|
int savedHistoryMessageLimit = 500;
|
||||||
bool agentHotkeyEnabled = true;
|
|
||||||
QString agentHotkeySequence = QStringLiteral("Alt+A");
|
|
||||||
bool agentPanelAutoShow = true;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,15 +74,6 @@ QJsonObject reminderObjectFromConfig(const AppConfig &config)
|
|||||||
return reminder;
|
return reminder;
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject agentObjectFromConfig(const AppConfig &config)
|
|
||||||
{
|
|
||||||
QJsonObject agent;
|
|
||||||
agent.insert(QStringLiteral("hotkeyEnabled"), config.agentHotkeyEnabled);
|
|
||||||
agent.insert(QStringLiteral("hotkeySequence"), config.agentHotkeySequence);
|
|
||||||
agent.insert(QStringLiteral("panelAutoShow"), config.agentPanelAutoShow);
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString normalizedProviderName(const QString &provider)
|
QString normalizedProviderName(const QString &provider)
|
||||||
{
|
{
|
||||||
const QString normalized = provider.trimmed().toLower();
|
const QString normalized = provider.trimmed().toLower();
|
||||||
@@ -310,24 +301,6 @@ AppConfig ConfigManager::loadAppConfig() const
|
|||||||
config.reminderSoundVolume = qBound(0.0, reminder.value(QStringLiteral("soundVolume")).toDouble(config.reminderSoundVolume), 1.0);
|
config.reminderSoundVolume = qBound(0.0, reminder.value(QStringLiteral("soundVolume")).toDouble(config.reminderSoundVolume), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QJsonObject agent = root.value(QStringLiteral("agent")).toObject();
|
|
||||||
if (agent.contains(QStringLiteral("hotkeyEnabled")))
|
|
||||||
{
|
|
||||||
config.agentHotkeyEnabled = agent.value(QStringLiteral("hotkeyEnabled")).toBool(config.agentHotkeyEnabled);
|
|
||||||
}
|
|
||||||
if (agent.contains(QStringLiteral("hotkeySequence")))
|
|
||||||
{
|
|
||||||
config.agentHotkeySequence = agent.value(QStringLiteral("hotkeySequence")).toString(config.agentHotkeySequence).trimmed();
|
|
||||||
if (config.agentHotkeySequence.isEmpty())
|
|
||||||
{
|
|
||||||
config.agentHotkeySequence = QStringLiteral("Alt+A");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (agent.contains(QStringLiteral("panelAutoShow")))
|
|
||||||
{
|
|
||||||
config.agentPanelAutoShow = agent.value(QStringLiteral("panelAutoShow")).toBool(config.agentPanelAutoShow);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +391,6 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
|
|||||||
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config));
|
root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("agent"), agentObjectFromConfig(config));
|
|
||||||
|
|
||||||
QFile file(appConfigPath());
|
QFile file(appConfigPath());
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
@@ -452,21 +424,6 @@ bool ConfigManager::saveAIConfigStore(const AIConfigStore &store) const
|
|||||||
return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
|
return file.write(document.toJson(QJsonDocument::Indented)) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigManager::saveAIConfig(const AIConfig &config) const
|
|
||||||
{
|
|
||||||
AIConfig normalizedConfig = config;
|
|
||||||
normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider);
|
|
||||||
if (isRemovedProviderName(normalizedConfig.provider))
|
|
||||||
{
|
|
||||||
normalizedConfig = defaultAIConfigForProvider(QStringLiteral("custom"));
|
|
||||||
}
|
|
||||||
|
|
||||||
AIConfigStore store = loadAIConfigStore();
|
|
||||||
store.activeProvider = normalizedConfig.provider;
|
|
||||||
store.providers.insert(normalizedConfig.provider, normalizedConfig);
|
|
||||||
return saveAIConfigStore(store);
|
|
||||||
}
|
|
||||||
|
|
||||||
QString ConfigManager::appConfigPath() const
|
QString ConfigManager::appConfigPath() const
|
||||||
{
|
{
|
||||||
return QDir(configDirectoryPath()).filePath(AppConfigFileName);
|
return QDir(configDirectoryPath()).filePath(AppConfigFileName);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ public:
|
|||||||
AIConfig loadAIConfig() const;
|
AIConfig loadAIConfig() const;
|
||||||
bool saveAppConfig(const AppConfig &config) const;
|
bool saveAppConfig(const AppConfig &config) const;
|
||||||
bool saveAIConfigStore(const AIConfigStore &store) const;
|
bool saveAIConfigStore(const AIConfigStore &store) const;
|
||||||
bool saveAIConfig(const AIConfig &config) const;
|
|
||||||
QString appConfigPath() const;
|
QString appConfigPath() const;
|
||||||
QString aiConfigPath() const;
|
QString aiConfigPath() const;
|
||||||
QString conversationHistoryPath() const;
|
QString conversationHistoryPath() const;
|
||||||
|
|||||||
@@ -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,209 +0,0 @@
|
|||||||
#include "GlobalHotkeyManager.h"
|
|
||||||
|
|
||||||
#include "../util/Logger.h"
|
|
||||||
|
|
||||||
#include <QCoreApplication>
|
|
||||||
#include <QKeyCombination>
|
|
||||||
#include <QKeySequence>
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
#ifdef Q_OS_WIN
|
|
||||||
#ifndef NOMINMAX
|
|
||||||
#define NOMINMAX
|
|
||||||
#endif
|
|
||||||
#include <windows.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr int HotkeyId = 0x4450;
|
|
||||||
|
|
||||||
#ifdef Q_OS_WIN
|
|
||||||
bool parseHotkey(const QString &text, UINT *modifiers, UINT *virtualKey, QString *errorMessage)
|
|
||||||
{
|
|
||||||
QKeySequence sequence = QKeySequence::fromString(text.trimmed(), QKeySequence::PortableText);
|
|
||||||
if (sequence.isEmpty())
|
|
||||||
{
|
|
||||||
sequence = QKeySequence::fromString(text.trimmed(), QKeySequence::NativeText);
|
|
||||||
}
|
|
||||||
if (sequence.isEmpty() || sequence.count() != 1)
|
|
||||||
{
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = QStringLiteral("快捷键为空或包含多个按键。");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QKeyCombination combination = sequence[0];
|
|
||||||
const Qt::KeyboardModifiers qtModifiers = combination.keyboardModifiers();
|
|
||||||
UINT nativeModifiers = 0;
|
|
||||||
if ((qtModifiers & Qt::AltModifier) != Qt::NoModifier)
|
|
||||||
{
|
|
||||||
nativeModifiers |= MOD_ALT;
|
|
||||||
}
|
|
||||||
if ((qtModifiers & Qt::ControlModifier) != Qt::NoModifier)
|
|
||||||
{
|
|
||||||
nativeModifiers |= MOD_CONTROL;
|
|
||||||
}
|
|
||||||
if ((qtModifiers & Qt::ShiftModifier) != Qt::NoModifier)
|
|
||||||
{
|
|
||||||
nativeModifiers |= MOD_SHIFT;
|
|
||||||
}
|
|
||||||
if ((qtModifiers & Qt::MetaModifier) != Qt::NoModifier)
|
|
||||||
{
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = QStringLiteral("暂不支持包含 Win/Meta 的全局快捷键。");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (nativeModifiers == 0)
|
|
||||||
{
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = QStringLiteral("快捷键必须包含 Ctrl、Alt 或 Shift。");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int key = combination.key();
|
|
||||||
UINT nativeKey = 0;
|
|
||||||
if (key >= Qt::Key_A && key <= Qt::Key_Z)
|
|
||||||
{
|
|
||||||
nativeKey = static_cast<UINT>('A' + key - Qt::Key_A);
|
|
||||||
}
|
|
||||||
else if (key >= Qt::Key_0 && key <= Qt::Key_9)
|
|
||||||
{
|
|
||||||
nativeKey = static_cast<UINT>('0' + key - Qt::Key_0);
|
|
||||||
}
|
|
||||||
else if (key >= Qt::Key_F1 && key <= Qt::Key_F12)
|
|
||||||
{
|
|
||||||
nativeKey = static_cast<UINT>(VK_F1 + key - Qt::Key_F1);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = QStringLiteral("快捷键主键只支持字母、数字或 F1-F12。");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modifiers != nullptr)
|
|
||||||
{
|
|
||||||
*modifiers = nativeModifiers;
|
|
||||||
}
|
|
||||||
if (virtualKey != nullptr)
|
|
||||||
{
|
|
||||||
*virtualKey = nativeKey;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
GlobalHotkeyManager::GlobalHotkeyManager() = default;
|
|
||||||
|
|
||||||
GlobalHotkeyManager::~GlobalHotkeyManager()
|
|
||||||
{
|
|
||||||
unregisterHotkey();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool GlobalHotkeyManager::registerHotkey(const QString &sequence, QString *errorMessage)
|
|
||||||
{
|
|
||||||
unregisterHotkey();
|
|
||||||
|
|
||||||
#ifdef Q_OS_WIN
|
|
||||||
UINT modifiers = 0;
|
|
||||||
UINT virtualKey = 0;
|
|
||||||
if (!parseHotkey(sequence, &modifiers, &virtualKey, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (QCoreApplication::instance() != nullptr && !m_filterInstalled)
|
|
||||||
{
|
|
||||||
QCoreApplication::instance()->installNativeEventFilter(this);
|
|
||||||
m_filterInstalled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RegisterHotKey(nullptr, HotkeyId, modifiers, virtualKey))
|
|
||||||
{
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = QStringLiteral("快捷键注册失败,可能已被其他程序占用。");
|
|
||||||
}
|
|
||||||
Logger::warning(QStringLiteral("Failed to register global hotkey: %1").arg(sequence));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_registered = true;
|
|
||||||
m_sequence = sequence;
|
|
||||||
return true;
|
|
||||||
#else
|
|
||||||
Q_UNUSED(sequence);
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = QStringLiteral("当前平台暂不支持全局快捷键。");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void GlobalHotkeyManager::unregisterHotkey()
|
|
||||||
{
|
|
||||||
#ifdef Q_OS_WIN
|
|
||||||
if (m_registered)
|
|
||||||
{
|
|
||||||
UnregisterHotKey(nullptr, HotkeyId);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
m_registered = false;
|
|
||||||
m_sequence.clear();
|
|
||||||
if (m_filterInstalled && QCoreApplication::instance() != nullptr)
|
|
||||||
{
|
|
||||||
QCoreApplication::instance()->removeNativeEventFilter(this);
|
|
||||||
m_filterInstalled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool GlobalHotkeyManager::isRegistered() const
|
|
||||||
{
|
|
||||||
return m_registered;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString GlobalHotkeyManager::sequence() const
|
|
||||||
{
|
|
||||||
return m_sequence;
|
|
||||||
}
|
|
||||||
|
|
||||||
void GlobalHotkeyManager::setActivatedCallback(ActivatedCallback callback)
|
|
||||||
{
|
|
||||||
m_callback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool GlobalHotkeyManager::nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result)
|
|
||||||
{
|
|
||||||
Q_UNUSED(eventType);
|
|
||||||
Q_UNUSED(result);
|
|
||||||
|
|
||||||
#ifdef Q_OS_WIN
|
|
||||||
MSG *msg = static_cast<MSG *>(message);
|
|
||||||
if (msg != nullptr && msg->message == WM_HOTKEY && static_cast<int>(msg->wParam) == HotkeyId)
|
|
||||||
{
|
|
||||||
if (m_callback)
|
|
||||||
{
|
|
||||||
m_callback();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
Q_UNUSED(message);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QAbstractNativeEventFilter>
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
class GlobalHotkeyManager : public QAbstractNativeEventFilter
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
using ActivatedCallback = std::function<void()>;
|
|
||||||
|
|
||||||
GlobalHotkeyManager();
|
|
||||||
~GlobalHotkeyManager() override;
|
|
||||||
|
|
||||||
bool registerHotkey(const QString &sequence, QString *errorMessage = nullptr);
|
|
||||||
void unregisterHotkey();
|
|
||||||
bool isRegistered() const;
|
|
||||||
QString sequence() const;
|
|
||||||
void setActivatedCallback(ActivatedCallback callback);
|
|
||||||
|
|
||||||
bool nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool m_registered = false;
|
|
||||||
bool m_filterInstalled = false;
|
|
||||||
QString m_sequence;
|
|
||||||
ActivatedCallback m_callback;
|
|
||||||
};
|
|
||||||
@@ -85,11 +85,6 @@ void TrayController::createMenu()
|
|||||||
hidePetWindow();
|
hidePetWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
QAction *workspacePanelAction = m_menu.addAction(QStringLiteral("Agent 工作区"));
|
|
||||||
QObject::connect(workspacePanelAction, &QAction::triggered, [this]() {
|
|
||||||
showWorkspacePanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
QAction *settingsAction = m_menu.addAction(QStringLiteral("设置..."));
|
QAction *settingsAction = m_menu.addAction(QStringLiteral("设置..."));
|
||||||
QObject::connect(settingsAction, &QAction::triggered, [this]() {
|
QObject::connect(settingsAction, &QAction::triggered, [this]() {
|
||||||
openSettings();
|
openSettings();
|
||||||
@@ -142,16 +137,6 @@ void TrayController::togglePetWindowVisibility()
|
|||||||
showPetWindow();
|
showPetWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TrayController::showWorkspacePanel()
|
|
||||||
{
|
|
||||||
if (m_petWindow == nullptr)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_petWindow->showWorkspacePanelFromUserAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
void TrayController::openSettings()
|
void TrayController::openSettings()
|
||||||
{
|
{
|
||||||
if (m_petWindow == nullptr)
|
if (m_petWindow == nullptr)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ private:
|
|||||||
void showPetWindow();
|
void showPetWindow();
|
||||||
void hidePetWindow();
|
void hidePetWindow();
|
||||||
void togglePetWindowVisibility();
|
void togglePetWindowVisibility();
|
||||||
void showWorkspacePanel();
|
|
||||||
void openSettings();
|
void openSettings();
|
||||||
void quitApplication();
|
void quitApplication();
|
||||||
|
|
||||||
|
|||||||
+1
-43
@@ -1,11 +1,11 @@
|
|||||||
#include "ChatBubble.h"
|
#include "ChatBubble.h"
|
||||||
|
|
||||||
#include <QApplication>
|
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QRect>
|
#include <QRect>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
|
#include <QTextEdit>
|
||||||
#include <QTextDocument>
|
#include <QTextDocument>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
@@ -48,7 +48,6 @@ ChatBubble::ChatBubble(QWidget *parent)
|
|||||||
m_textEdit->installEventFilter(this);
|
m_textEdit->installEventFilter(this);
|
||||||
m_textEdit->viewport()->installEventFilter(this);
|
m_textEdit->viewport()->installEventFilter(this);
|
||||||
m_textEdit->verticalScrollBar()->installEventFilter(this);
|
m_textEdit->verticalScrollBar()->installEventFilter(this);
|
||||||
qApp->installEventFilter(this);
|
|
||||||
m_textEdit->setStyleSheet(QStringLiteral(
|
m_textEdit->setStyleSheet(QStringLiteral(
|
||||||
"QTextEdit {"
|
"QTextEdit {"
|
||||||
"background: rgba(255, 255, 255, 232);"
|
"background: rgba(255, 255, 255, 232);"
|
||||||
@@ -80,17 +79,8 @@ ChatBubble::ChatBubble(QWidget *parent)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatBubble::~ChatBubble()
|
|
||||||
{
|
|
||||||
if (qApp != nullptr)
|
|
||||||
{
|
|
||||||
qApp->removeEventFilter(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom)
|
void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom)
|
||||||
{
|
{
|
||||||
m_dismissOnExternalInteraction = false;
|
|
||||||
m_anchorPosition = anchorPosition;
|
m_anchorPosition = anchorPosition;
|
||||||
m_autoHideDurationMs = durationMs;
|
m_autoHideDurationMs = durationMs;
|
||||||
const QString trimmed = message.trimmed();
|
const QString trimmed = message.trimmed();
|
||||||
@@ -129,11 +119,6 @@ void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatBubble::setDismissOnExternalInteraction(bool enabled)
|
|
||||||
{
|
|
||||||
m_dismissOnExternalInteraction = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChatBubble::resetAutoHideTimer()
|
void ChatBubble::resetAutoHideTimer()
|
||||||
{
|
{
|
||||||
if (isVisible() && m_autoHideDurationMs > 0)
|
if (isVisible() && m_autoHideDurationMs > 0)
|
||||||
@@ -150,18 +135,6 @@ void ChatBubble::hideBubble()
|
|||||||
|
|
||||||
bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
|
bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
|
||||||
{
|
{
|
||||||
if (watched == qApp && m_dismissOnExternalInteraction && event->type() == QEvent::ApplicationDeactivate)
|
|
||||||
{
|
|
||||||
hideBubble();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_dismissOnExternalInteraction && event->type() == QEvent::MouseButtonPress && !isOwnObject(watched))
|
|
||||||
{
|
|
||||||
hideBubble();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((watched == m_textEdit || watched == m_textEdit->viewport() || watched == m_textEdit->verticalScrollBar())
|
if ((watched == m_textEdit || watched == m_textEdit->viewport() || watched == m_textEdit->verticalScrollBar())
|
||||||
&& isUserInteractionEvent(event))
|
&& isUserInteractionEvent(event))
|
||||||
{
|
{
|
||||||
@@ -171,21 +144,6 @@ bool ChatBubble::eventFilter(QObject *watched, QEvent *event)
|
|||||||
return QWidget::eventFilter(watched, event);
|
return QWidget::eventFilter(watched, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatBubble::isOwnObject(QObject *object) const
|
|
||||||
{
|
|
||||||
while (object != nullptr)
|
|
||||||
{
|
|
||||||
if (object == this || object == m_textEdit || object == m_textEdit->viewport() || object == m_textEdit->verticalScrollBar())
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
object = object->parent();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ChatBubble::isUserInteractionEvent(QEvent *event) const
|
bool ChatBubble::isUserInteractionEvent(QEvent *event) const
|
||||||
{
|
{
|
||||||
switch (event->type())
|
switch (event->type())
|
||||||
|
|||||||
+1
-5
@@ -1,16 +1,15 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QTextEdit>
|
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
class QEvent;
|
class QEvent;
|
||||||
|
class QTextEdit;
|
||||||
|
|
||||||
class ChatBubble : public QWidget
|
class ChatBubble : public QWidget
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit ChatBubble(QWidget *parent = nullptr);
|
explicit ChatBubble(QWidget *parent = nullptr);
|
||||||
~ChatBubble() override;
|
|
||||||
|
|
||||||
void showMessage(
|
void showMessage(
|
||||||
const QString &message,
|
const QString &message,
|
||||||
@@ -18,7 +17,6 @@ public:
|
|||||||
int durationMs = 10000,
|
int durationMs = 10000,
|
||||||
bool scrollToBottom = false);
|
bool scrollToBottom = false);
|
||||||
void updateAnchorPosition(const QPoint &anchorPosition);
|
void updateAnchorPosition(const QPoint &anchorPosition);
|
||||||
void setDismissOnExternalInteraction(bool enabled);
|
|
||||||
void resetAutoHideTimer();
|
void resetAutoHideTimer();
|
||||||
void hideBubble();
|
void hideBubble();
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ protected:
|
|||||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool isOwnObject(QObject *object) const;
|
|
||||||
bool isUserInteractionEvent(QEvent *event) const;
|
bool isUserInteractionEvent(QEvent *event) const;
|
||||||
QSize preferredBubbleSize(const QString &message) const;
|
QSize preferredBubbleSize(const QString &message) const;
|
||||||
void updatePosition();
|
void updatePosition();
|
||||||
@@ -35,5 +32,4 @@ private:
|
|||||||
QTimer m_hideTimer;
|
QTimer m_hideTimer;
|
||||||
QPoint m_anchorPosition;
|
QPoint m_anchorPosition;
|
||||||
int m_autoHideDurationMs = 0;
|
int m_autoHideDurationMs = 0;
|
||||||
bool m_dismissOnExternalInteraction = false;
|
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-857
File diff suppressed because it is too large
Load Diff
@@ -30,19 +30,12 @@ class ChatHistoryPanel;
|
|||||||
class ChatInputDialog;
|
class ChatInputDialog;
|
||||||
class ConversationManager;
|
class ConversationManager;
|
||||||
class ConversationStore;
|
class ConversationStore;
|
||||||
class FileOperationManager;
|
|
||||||
class GlobalHotkeyManager;
|
|
||||||
class LLMProvider;
|
|
||||||
class AppLaunchManager;
|
|
||||||
class NotificationDispatcher;
|
class NotificationDispatcher;
|
||||||
class PetView;
|
class PetView;
|
||||||
class ReminderManager;
|
class ReminderManager;
|
||||||
class ReminderSoundPlayer;
|
class ReminderSoundPlayer;
|
||||||
class WebChatManager;
|
class WebChatManager;
|
||||||
class WeatherManager;
|
class WeatherManager;
|
||||||
class WorkspaceController;
|
|
||||||
class WorkspaceAgentWindow;
|
|
||||||
struct WorkspaceToolResult;
|
|
||||||
|
|
||||||
class PetWindow : public QWidget
|
class PetWindow : public QWidget
|
||||||
{
|
{
|
||||||
@@ -59,8 +52,6 @@ public:
|
|||||||
void pauseAnimation();
|
void pauseAnimation();
|
||||||
void resumeAnimation();
|
void resumeAnimation();
|
||||||
void showBubbleMessage(const QString &message);
|
void showBubbleMessage(const QString &message);
|
||||||
void showWorkspaceAgentWindowFromUserAction();
|
|
||||||
void showWorkspacePanelFromUserAction();
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void contextMenuEvent(QContextMenuEvent *event) override;
|
void contextMenuEvent(QContextMenuEvent *event) override;
|
||||||
@@ -83,23 +74,8 @@ private:
|
|||||||
bool submitWebChatMessage(const QString &message);
|
bool submitWebChatMessage(const QString &message);
|
||||||
bool handleReminderChatMessage(const QString &message);
|
bool handleReminderChatMessage(const QString &message);
|
||||||
bool handleWeatherChatMessage(const QString &message);
|
bool handleWeatherChatMessage(const QString &message);
|
||||||
bool handleWorkspaceChatMessage(const QString &message);
|
|
||||||
bool consumeWorkspaceToolResult(const WorkspaceToolResult &result);
|
|
||||||
bool submitWorkspaceAgentWindowMessage(const QString &message);
|
|
||||||
bool handleFileOperationChatMessage(const QString &message);
|
|
||||||
bool handleLaunchAppChatMessage(const QString &message);
|
|
||||||
bool hasActiveWebRequest() const;
|
bool hasActiveWebRequest() const;
|
||||||
bool hasActiveWorkspaceAIRequest() const;
|
|
||||||
QString formatWebChatResponseForDisplay(const WebChatResponse &response) const;
|
QString formatWebChatResponseForDisplay(const WebChatResponse &response) const;
|
||||||
void appendWorkspaceAgentMessage(const QString &title, const QString &content, bool error = false);
|
|
||||||
void appendWorkspaceUserTask(const QString &message);
|
|
||||||
void showWorkspacePanel();
|
|
||||||
void refreshWorkspacePanel();
|
|
||||||
void configureAgentHotkey();
|
|
||||||
void handleWorkspacePanelRead(int resultIndex);
|
|
||||||
void handleWorkspacePanelOpen(int resultIndex);
|
|
||||||
void handleWorkspacePanelReveal(int resultIndex);
|
|
||||||
void requestWorkspaceAiSummary(const WorkspaceToolResult &result);
|
|
||||||
void refreshChatInputWebToggle();
|
void refreshChatInputWebToggle();
|
||||||
void saveWebTogglePreference(bool webEnabled);
|
void saveWebTogglePreference(bool webEnabled);
|
||||||
void handleTriggeredReminder(const ReminderItem &item);
|
void handleTriggeredReminder(const ReminderItem &item);
|
||||||
@@ -162,12 +138,6 @@ private:
|
|||||||
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
std::unique_ptr<ChatInputDialog> m_chatInputDialog;
|
||||||
std::unique_ptr<ConversationManager> m_conversationManager;
|
std::unique_ptr<ConversationManager> m_conversationManager;
|
||||||
std::unique_ptr<ConversationStore> m_conversationStore;
|
std::unique_ptr<ConversationStore> m_conversationStore;
|
||||||
std::unique_ptr<FileOperationManager> m_fileOperationManager;
|
|
||||||
std::unique_ptr<WorkspaceController> m_workspaceController;
|
|
||||||
std::unique_ptr<WorkspaceAgentWindow> m_workspaceAgentWindow;
|
|
||||||
std::unique_ptr<LLMProvider> m_workspaceAiProvider;
|
|
||||||
std::unique_ptr<GlobalHotkeyManager> m_globalHotkeyManager;
|
|
||||||
std::unique_ptr<AppLaunchManager> m_appLaunchManager;
|
|
||||||
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
|
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
|
||||||
std::unique_ptr<ReminderManager> m_reminderManager;
|
std::unique_ptr<ReminderManager> m_reminderManager;
|
||||||
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
|
std::unique_ptr<ReminderSoundPlayer> m_reminderSoundPlayer;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
#include "../ai/LLMTypes.h"
|
#include "../ai/LLMTypes.h"
|
||||||
#include "../character/CharacterPackageRepository.h"
|
#include "../character/CharacterPackageRepository.h"
|
||||||
#include "../config/SecretStore.h"
|
#include "../config/SecretStore.h"
|
||||||
#include "../launcher/AppLaunchManager.h"
|
|
||||||
#include "../reminder/ReminderSoundRepository.h"
|
#include "../reminder/ReminderSoundRepository.h"
|
||||||
#include "../web/WebCapabilityDetector.h"
|
#include "../web/WebCapabilityDetector.h"
|
||||||
#include "../web/WebChatManager.h"
|
#include "../web/WebChatManager.h"
|
||||||
@@ -21,15 +20,11 @@
|
|||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QInputDialog>
|
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QListWidget>
|
#include <QListWidget>
|
||||||
#include <QListWidgetItem>
|
#include <QListWidgetItem>
|
||||||
#include <QKeySequence>
|
|
||||||
#include <QKeyCombination>
|
|
||||||
#include <QKeySequenceEdit>
|
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPair>
|
#include <QPair>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
@@ -228,68 +223,6 @@ QString webCapabilityDisplayText(const WebCapability &capability)
|
|||||||
return QStringLiteral("当前联网能力:%1。%2").arg(state, capability.userMessage);
|
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'));
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isSupportedAgentHotkey(const QKeySequence &sequence)
|
|
||||||
{
|
|
||||||
if (sequence.isEmpty() || sequence.count() != 1)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QKeyCombination combination = sequence[0];
|
|
||||||
const Qt::KeyboardModifiers modifiers = combination.keyboardModifiers();
|
|
||||||
if ((modifiers & (Qt::AltModifier | Qt::ControlModifier | Qt::ShiftModifier)) == Qt::NoModifier)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ((modifiers & Qt::MetaModifier) != Qt::NoModifier)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int key = combination.key();
|
|
||||||
return (key >= Qt::Key_A && key <= Qt::Key_Z)
|
|
||||||
|| (key >= Qt::Key_0 && key <= Qt::Key_9)
|
|
||||||
|| (key >= Qt::Key_F1 && key <= Qt::Key_F12);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
QString chatRoleDisplayText(const QString &role)
|
||||||
{
|
{
|
||||||
if (role == QStringLiteral("user"))
|
if (role == QStringLiteral("user"))
|
||||||
@@ -541,7 +474,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
const AppConfig &appConfig,
|
const AppConfig &appConfig,
|
||||||
const WeatherConfig &weatherConfig,
|
const WeatherConfig &weatherConfig,
|
||||||
const WebConfig &webConfig,
|
const WebConfig &webConfig,
|
||||||
const AppLaunchConfig &appLaunchConfig,
|
|
||||||
const QVector<ChatMessage> &conversationHistory,
|
const QVector<ChatMessage> &conversationHistory,
|
||||||
const QVector<ReminderItem> &reminders,
|
const QVector<ReminderItem> &reminders,
|
||||||
std::function<bool()> aiTestBlocked,
|
std::function<bool()> aiTestBlocked,
|
||||||
@@ -594,19 +526,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
, m_webShowCitationsCheckBox(new QCheckBox(QStringLiteral("显示来源引用"), this))
|
, m_webShowCitationsCheckBox(new QCheckBox(QStringLiteral("显示来源引用"), this))
|
||||||
, m_testWebButton(new QPushButton(QStringLiteral("测试联网模式"), this))
|
, m_testWebButton(new QPushButton(QStringLiteral("测试联网模式"), this))
|
||||||
, m_webStatusLabel(new QLabel(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_agentHotkeyEnabledCheckBox(new QCheckBox(QStringLiteral("启用 Agent 工作区窗口快捷键"), this))
|
|
||||||
, m_agentHotkeyEdit(new QKeySequenceEdit(this))
|
|
||||||
, m_agentResetHotkeyButton(new QPushButton(QStringLiteral("恢复默认快捷键"), this))
|
|
||||||
, m_agentPanelAutoShowCheckBox(new QCheckBox(QStringLiteral("工作区命令完成后自动显示 Agent 窗口"), this))
|
|
||||||
, m_agentStatusLabel(new QLabel(this))
|
|
||||||
, m_characterComboBox(new QComboBox(this))
|
, m_characterComboBox(new QComboBox(this))
|
||||||
, m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this))
|
, m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this))
|
||||||
, m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this))
|
, m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this))
|
||||||
@@ -630,7 +549,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
, m_appConfig(appConfig)
|
, m_appConfig(appConfig)
|
||||||
, m_weatherConfig(weatherConfig)
|
, m_weatherConfig(weatherConfig)
|
||||||
, m_webConfig(webConfig)
|
, m_webConfig(webConfig)
|
||||||
, m_appLaunchConfig(appLaunchConfig)
|
|
||||||
, m_conversationHistory(conversationHistory)
|
, m_conversationHistory(conversationHistory)
|
||||||
, m_reminders(reminders)
|
, m_reminders(reminders)
|
||||||
, m_aiTestBlocked(std::move(aiTestBlocked))
|
, m_aiTestBlocked(std::move(aiTestBlocked))
|
||||||
@@ -639,7 +557,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
, m_updateReminder(std::move(updateReminderCallback))
|
, m_updateReminder(std::move(updateReminderCallback))
|
||||||
, m_clearFinishedReminders(std::move(clearFinishedRemindersCallback))
|
, m_clearFinishedReminders(std::move(clearFinishedRemindersCallback))
|
||||||
, m_playReminderSound(std::move(playReminderSoundCallback))
|
, m_playReminderSound(std::move(playReminderSoundCallback))
|
||||||
, m_launchTestManager(std::make_unique<AppLaunchManager>())
|
|
||||||
, m_webTestManager(std::make_unique<WebChatManager>())
|
, m_webTestManager(std::make_unique<WebChatManager>())
|
||||||
, m_weatherTestManager(std::make_unique<WeatherManager>())
|
, m_weatherTestManager(std::make_unique<WeatherManager>())
|
||||||
{
|
{
|
||||||
@@ -962,92 +879,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
auto *webPage = new QWidget(this);
|
auto *webPage = new QWidget(this);
|
||||||
webPage->setLayout(webPageLayout);
|
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_agentStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
|
||||||
m_agentStatusLabel->setWordWrap(true);
|
|
||||||
m_agentStatusLabel->setText(QStringLiteral("当前 Agent 只读:可选择工作区、搜索、读取、打开和定位文件;不支持创建、修改、删除或执行命令。"));
|
|
||||||
m_agentHotkeyEnabledCheckBox->setChecked(m_appConfig.agentHotkeyEnabled);
|
|
||||||
m_agentHotkeyEdit->setKeySequence(QKeySequence::fromString(m_appConfig.agentHotkeySequence, QKeySequence::PortableText));
|
|
||||||
if (m_agentHotkeyEdit->keySequence().isEmpty())
|
|
||||||
{
|
|
||||||
m_agentHotkeyEdit->setKeySequence(QKeySequence::fromString(QStringLiteral("Alt+A"), QKeySequence::PortableText));
|
|
||||||
}
|
|
||||||
m_agentPanelAutoShowCheckBox->setChecked(m_appConfig.agentPanelAutoShow);
|
|
||||||
|
|
||||||
auto *agentTitleLabel = new QLabel(QStringLiteral("Agent"), this);
|
|
||||||
agentTitleLabel->setObjectName(QStringLiteral("PageTitle"));
|
|
||||||
auto *agentHintLabel = new QLabel(QStringLiteral("工作区 Agent 独立窗口用于承载搜索结果、文本命中、任务对话和最近读取内容。快捷键仅用于呼出窗口,不会执行写操作。"), this);
|
|
||||||
agentHintLabel->setObjectName(QStringLiteral("HintLabel"));
|
|
||||||
agentHintLabel->setWordWrap(true);
|
|
||||||
|
|
||||||
auto *agentFormLayout = new QFormLayout();
|
|
||||||
agentFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
|
||||||
agentFormLayout->setLabelAlignment(Qt::AlignRight);
|
|
||||||
agentFormLayout->setHorizontalSpacing(18);
|
|
||||||
agentFormLayout->setVerticalSpacing(12);
|
|
||||||
agentFormLayout->addRow(QString(), m_agentHotkeyEnabledCheckBox);
|
|
||||||
agentFormLayout->addRow(QStringLiteral("面板快捷键"), m_agentHotkeyEdit);
|
|
||||||
agentFormLayout->addRow(QString(), m_agentPanelAutoShowCheckBox);
|
|
||||||
|
|
||||||
auto *agentActionLayout = new QHBoxLayout();
|
|
||||||
agentActionLayout->addWidget(m_agentResetHotkeyButton);
|
|
||||||
agentActionLayout->addWidget(m_agentStatusLabel, 1);
|
|
||||||
|
|
||||||
auto *agentPageLayout = new QVBoxLayout();
|
|
||||||
agentPageLayout->setContentsMargins(24, 24, 24, 24);
|
|
||||||
agentPageLayout->setSpacing(16);
|
|
||||||
agentPageLayout->addWidget(agentTitleLabel);
|
|
||||||
agentPageLayout->addWidget(agentHintLabel);
|
|
||||||
agentPageLayout->addLayout(agentFormLayout);
|
|
||||||
agentPageLayout->addLayout(agentActionLayout);
|
|
||||||
agentPageLayout->addStretch();
|
|
||||||
|
|
||||||
auto *agentPage = new QWidget(this);
|
|
||||||
agentPage->setLayout(agentPageLayout);
|
|
||||||
|
|
||||||
m_reminderStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
m_reminderStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||||
m_reminderStatusLabel->setWordWrap(true);
|
m_reminderStatusLabel->setWordWrap(true);
|
||||||
@@ -1164,8 +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("应用启动"));
|
|
||||||
navigationList->addItem(QStringLiteral("Agent"));
|
|
||||||
navigationList->addItem(QStringLiteral("提醒"));
|
navigationList->addItem(QStringLiteral("提醒"));
|
||||||
navigationList->addItem(QStringLiteral("应用"));
|
navigationList->addItem(QStringLiteral("应用"));
|
||||||
navigationList->addItem(QStringLiteral("角色"));
|
navigationList->addItem(QStringLiteral("角色"));
|
||||||
@@ -1176,8 +1005,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
pageStack->addWidget(chatPage);
|
pageStack->addWidget(chatPage);
|
||||||
pageStack->addWidget(weatherPage);
|
pageStack->addWidget(weatherPage);
|
||||||
pageStack->addWidget(webPage);
|
pageStack->addWidget(webPage);
|
||||||
pageStack->addWidget(launchPage);
|
|
||||||
pageStack->addWidget(agentPage);
|
|
||||||
pageStack->addWidget(reminderPage);
|
pageStack->addWidget(reminderPage);
|
||||||
pageStack->addWidget(appPage);
|
pageStack->addWidget(appPage);
|
||||||
pageStack->addWidget(characterPage);
|
pageStack->addWidget(characterPage);
|
||||||
@@ -1229,22 +1056,12 @@ SettingsDialog::SettingsDialog(
|
|||||||
" background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;"
|
" background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;"
|
||||||
" padding: 0px;"
|
" padding: 0px;"
|
||||||
"}"
|
"}"
|
||||||
"QListWidget#LaunchAppList {"
|
|
||||||
" background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;"
|
|
||||||
" padding: 0px;"
|
|
||||||
"}"
|
|
||||||
"QListWidget#ReminderList::item {"
|
"QListWidget#ReminderList::item {"
|
||||||
" min-height: 28px; padding: 5px 8px; color: #344054;"
|
" min-height: 28px; padding: 5px 8px; color: #344054;"
|
||||||
"}"
|
"}"
|
||||||
"QListWidget#LaunchAppList::item {"
|
|
||||||
" min-height: 34px; padding: 6px 8px; color: #344054;"
|
|
||||||
"}"
|
|
||||||
"QListWidget#ReminderList::item:selected {"
|
"QListWidget#ReminderList::item:selected {"
|
||||||
" background: #eaf3ff; color: #175cd3;"
|
" background: #eaf3ff; color: #175cd3;"
|
||||||
"}"
|
"}"
|
||||||
"QListWidget#LaunchAppList::item:selected {"
|
|
||||||
" background: #eaf3ff; color: #175cd3;"
|
|
||||||
"}"
|
|
||||||
"QStackedWidget#SettingsPages {"
|
"QStackedWidget#SettingsPages {"
|
||||||
" background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;"
|
" background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;"
|
||||||
"}"
|
"}"
|
||||||
@@ -1302,26 +1119,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
connect(m_testWebButton, &QPushButton::clicked, this, [this]() {
|
connect(m_testWebButton, &QPushButton::clicked, this, [this]() {
|
||||||
testWebMode();
|
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_agentResetHotkeyButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
m_agentHotkeyEdit->setKeySequence(QKeySequence::fromString(QStringLiteral("Alt+A"), QKeySequence::PortableText));
|
|
||||||
});
|
|
||||||
connect(m_agentHotkeyEnabledCheckBox, &QCheckBox::toggled, m_agentHotkeyEdit, &QKeySequenceEdit::setEnabled);
|
|
||||||
m_agentHotkeyEdit->setEnabled(m_agentHotkeyEnabledCheckBox->isChecked());
|
|
||||||
connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() {
|
connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||||
importCharacterFolder();
|
importCharacterFolder();
|
||||||
});
|
});
|
||||||
@@ -1363,7 +1160,6 @@ SettingsDialog::SettingsDialog(
|
|||||||
});
|
});
|
||||||
updateReminderActionButtons();
|
updateReminderActionButtons();
|
||||||
updateReminderSoundButtons();
|
updateReminderSoundButtons();
|
||||||
updateLaunchAppButtons();
|
|
||||||
refreshWebCapabilityStatus();
|
refreshWebCapabilityStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1431,13 +1227,6 @@ AppConfig SettingsDialog::appConfig() const
|
|||||||
}
|
}
|
||||||
config.reminderSoundEnabled = m_reminderSoundEnabledCheckBox->isChecked();
|
config.reminderSoundEnabled = m_reminderSoundEnabledCheckBox->isChecked();
|
||||||
config.reminderSoundVolume = qBound(0.0, m_reminderSoundVolumeSpinBox->value() / 100.0, 1.0);
|
config.reminderSoundVolume = qBound(0.0, m_reminderSoundVolumeSpinBox->value() / 100.0, 1.0);
|
||||||
config.agentHotkeyEnabled = m_agentHotkeyEnabledCheckBox->isChecked();
|
|
||||||
config.agentHotkeySequence = m_agentHotkeyEdit->keySequence().toString(QKeySequence::PortableText).trimmed();
|
|
||||||
if (config.agentHotkeySequence.isEmpty())
|
|
||||||
{
|
|
||||||
config.agentHotkeySequence = QStringLiteral("Alt+A");
|
|
||||||
}
|
|
||||||
config.agentPanelAutoShow = m_agentPanelAutoShowCheckBox->isChecked();
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,15 +1257,6 @@ WebConfig SettingsDialog::webConfig() const
|
|||||||
return config;
|
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()
|
void SettingsDialog::testWeatherDefaultCity()
|
||||||
{
|
{
|
||||||
const QString cityName = m_weatherDefaultCityEdit->text().trimmed();
|
const QString cityName = m_weatherDefaultCityEdit->text().trimmed();
|
||||||
@@ -1596,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()
|
void SettingsDialog::accept()
|
||||||
{
|
{
|
||||||
@@ -1836,15 +1389,6 @@ void SettingsDialog::accept()
|
|||||||
QStringLiteral("当前平台不支持内置加密,且未允许明文保存。API Key 不会写入配置文件。"));
|
QStringLiteral("当前平台不支持内置加密,且未允许明文保存。API Key 不会写入配置文件。"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_agentHotkeyEnabledCheckBox->isChecked() && !isSupportedAgentHotkey(m_agentHotkeyEdit->keySequence()))
|
|
||||||
{
|
|
||||||
QMessageBox::warning(
|
|
||||||
this,
|
|
||||||
QStringLiteral("快捷键不可用"),
|
|
||||||
QStringLiteral("Agent 工作区窗口快捷键需要包含 Ctrl/Alt/Shift,并使用字母、数字或 F1-F12 作为主键。"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AIConfigStore store;
|
AIConfigStore store;
|
||||||
QString errorMessage;
|
QString errorMessage;
|
||||||
if (!buildAIConfigStore(&store, &errorMessage))
|
if (!buildAIConfigStore(&store, &errorMessage))
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
#include "../config/AIConfig.h"
|
#include "../config/AIConfig.h"
|
||||||
#include "../config/AppConfig.h"
|
#include "../config/AppConfig.h"
|
||||||
#include "../ai/LLMTypes.h"
|
#include "../ai/LLMTypes.h"
|
||||||
#include "../launcher/AppLaunchTypes.h"
|
|
||||||
#include "../reminder/ReminderTypes.h"
|
#include "../reminder/ReminderTypes.h"
|
||||||
#include "../web/WebConfig.h"
|
#include "../web/WebConfig.h"
|
||||||
#include "../weather/WeatherConfig.h"
|
#include "../weather/WeatherConfig.h"
|
||||||
@@ -20,11 +19,9 @@ class QDoubleSpinBox;
|
|||||||
class QLabel;
|
class QLabel;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QListWidget;
|
class QListWidget;
|
||||||
class QKeySequenceEdit;
|
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QSpinBox;
|
class QSpinBox;
|
||||||
class LLMProvider;
|
class LLMProvider;
|
||||||
class AppLaunchManager;
|
|
||||||
class WebChatManager;
|
class WebChatManager;
|
||||||
class WeatherManager;
|
class WeatherManager;
|
||||||
|
|
||||||
@@ -36,7 +33,6 @@ public:
|
|||||||
const AppConfig &appConfig,
|
const AppConfig &appConfig,
|
||||||
const WeatherConfig &weatherConfig,
|
const WeatherConfig &weatherConfig,
|
||||||
const WebConfig &webConfig,
|
const WebConfig &webConfig,
|
||||||
const AppLaunchConfig &appLaunchConfig,
|
|
||||||
const QVector<ChatMessage> &conversationHistory,
|
const QVector<ChatMessage> &conversationHistory,
|
||||||
const QVector<ReminderItem> &reminders,
|
const QVector<ReminderItem> &reminders,
|
||||||
std::function<bool()> aiTestBlocked,
|
std::function<bool()> aiTestBlocked,
|
||||||
@@ -52,7 +48,6 @@ public:
|
|||||||
AppConfig appConfig() const;
|
AppConfig appConfig() const;
|
||||||
WeatherConfig weatherConfig() const;
|
WeatherConfig weatherConfig() const;
|
||||||
WebConfig webConfig() const;
|
WebConfig webConfig() const;
|
||||||
AppLaunchConfig appLaunchConfig() const;
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void accept() override;
|
void accept() override;
|
||||||
@@ -91,13 +86,6 @@ private:
|
|||||||
void testWeatherDefaultCity();
|
void testWeatherDefaultCity();
|
||||||
void refreshWebCapabilityStatus();
|
void refreshWebCapabilityStatus();
|
||||||
void testWebMode();
|
void testWebMode();
|
||||||
void reloadLaunchAppList();
|
|
||||||
void updateLaunchAppButtons();
|
|
||||||
QString selectedLaunchAppId() const;
|
|
||||||
void addLaunchApp();
|
|
||||||
void editSelectedLaunchApp();
|
|
||||||
void deleteSelectedLaunchApp();
|
|
||||||
void testSelectedLaunchApp();
|
|
||||||
|
|
||||||
QComboBox *m_providerComboBox = nullptr;
|
QComboBox *m_providerComboBox = nullptr;
|
||||||
QLineEdit *m_baseUrlEdit = nullptr;
|
QLineEdit *m_baseUrlEdit = nullptr;
|
||||||
@@ -143,19 +131,6 @@ private:
|
|||||||
QCheckBox *m_webShowCitationsCheckBox = nullptr;
|
QCheckBox *m_webShowCitationsCheckBox = nullptr;
|
||||||
QPushButton *m_testWebButton = nullptr;
|
QPushButton *m_testWebButton = nullptr;
|
||||||
QLabel *m_webStatusLabel = 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;
|
|
||||||
QCheckBox *m_agentHotkeyEnabledCheckBox = nullptr;
|
|
||||||
QKeySequenceEdit *m_agentHotkeyEdit = nullptr;
|
|
||||||
QPushButton *m_agentResetHotkeyButton = nullptr;
|
|
||||||
QCheckBox *m_agentPanelAutoShowCheckBox = nullptr;
|
|
||||||
QLabel *m_agentStatusLabel = nullptr;
|
|
||||||
QComboBox *m_characterComboBox = nullptr;
|
QComboBox *m_characterComboBox = nullptr;
|
||||||
QPushButton *m_importCharacterButton = nullptr;
|
QPushButton *m_importCharacterButton = nullptr;
|
||||||
QPushButton *m_deleteCharacterButton = nullptr;
|
QPushButton *m_deleteCharacterButton = nullptr;
|
||||||
@@ -180,7 +155,6 @@ private:
|
|||||||
AppConfig m_appConfig;
|
AppConfig m_appConfig;
|
||||||
WeatherConfig m_weatherConfig;
|
WeatherConfig m_weatherConfig;
|
||||||
WebConfig m_webConfig;
|
WebConfig m_webConfig;
|
||||||
AppLaunchConfig m_appLaunchConfig;
|
|
||||||
QVector<ChatMessage> m_conversationHistory;
|
QVector<ChatMessage> m_conversationHistory;
|
||||||
QVector<ReminderItem> m_reminders;
|
QVector<ReminderItem> m_reminders;
|
||||||
QString m_currentProvider;
|
QString m_currentProvider;
|
||||||
@@ -191,7 +165,6 @@ private:
|
|||||||
std::function<bool(QString *)> m_clearFinishedReminders;
|
std::function<bool(QString *)> m_clearFinishedReminders;
|
||||||
std::function<void(const QString &, double)> m_playReminderSound;
|
std::function<void(const QString &, double)> m_playReminderSound;
|
||||||
std::unique_ptr<LLMProvider> m_testProvider;
|
std::unique_ptr<LLMProvider> m_testProvider;
|
||||||
std::unique_ptr<AppLaunchManager> m_launchTestManager;
|
|
||||||
std::unique_ptr<WebChatManager> m_webTestManager;
|
std::unique_ptr<WebChatManager> m_webTestManager;
|
||||||
std::unique_ptr<WeatherManager> m_weatherTestManager;
|
std::unique_ptr<WeatherManager> m_weatherTestManager;
|
||||||
bool m_hasAcceptedConfigStore = false;
|
bool m_hasAcceptedConfigStore = false;
|
||||||
|
|||||||
@@ -1,676 +0,0 @@
|
|||||||
#include "WorkspaceAgentWindow.h"
|
|
||||||
|
|
||||||
#include <QAbstractItemView>
|
|
||||||
#include <QCloseEvent>
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QFrame>
|
|
||||||
#include <QHBoxLayout>
|
|
||||||
#include <QKeySequence>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QLineEdit>
|
|
||||||
#include <QListWidget>
|
|
||||||
#include <QListWidgetItem>
|
|
||||||
#include <QMouseEvent>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QSplitter>
|
|
||||||
#include <QTextEdit>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr int DefaultWindowWidth = 1180;
|
|
||||||
constexpr int DefaultWindowHeight = 760;
|
|
||||||
constexpr int MinWindowWidth = 960;
|
|
||||||
constexpr int MinWindowHeight = 600;
|
|
||||||
constexpr int MaxTextMatchesInSidebar = 80;
|
|
||||||
constexpr int MaxTextMatchesInInspector = 20;
|
|
||||||
|
|
||||||
QString htmlEscaped(QString text)
|
|
||||||
{
|
|
||||||
return text.toHtmlEscaped().replace(QStringLiteral("\n"), QStringLiteral("<br>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
QString chatBubbleHtml(
|
|
||||||
const QString &role,
|
|
||||||
const QString &content,
|
|
||||||
Qt::Alignment alignment,
|
|
||||||
const QString &backgroundColor,
|
|
||||||
const QString &borderColor,
|
|
||||||
const QString &roleColor,
|
|
||||||
const QString &contentColor)
|
|
||||||
{
|
|
||||||
const QString horizontalAlignment = alignment == Qt::AlignRight
|
|
||||||
? QStringLiteral("right")
|
|
||||||
: QStringLiteral("left");
|
|
||||||
return QStringLiteral(
|
|
||||||
"<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"margin-top:14px; margin-bottom:18px;\">"
|
|
||||||
"<tr><td align=\"%1\">"
|
|
||||||
"<table width=\"76%\" align=\"%1\" cellspacing=\"0\" cellpadding=\"0\" bgcolor=\"%2\" "
|
|
||||||
"style=\"background:%2; border:1px solid %3; border-radius:8px;\">"
|
|
||||||
"<tr><td style=\"padding:12px 14px;\">"
|
|
||||||
"<div style=\"color:%4; font-size:12px; font-weight:700; margin-bottom:7px;\">%5</div>"
|
|
||||||
"<div style=\"color:%6; font-size:13px; line-height:1.55; white-space:pre-wrap;\">%7</div>"
|
|
||||||
"</td></tr></table>"
|
|
||||||
"</td></tr></table>"
|
|
||||||
"<div style=\"margin-bottom:12px;\"><br></div>")
|
|
||||||
.arg(horizontalAlignment)
|
|
||||||
.arg(backgroundColor)
|
|
||||||
.arg(borderColor)
|
|
||||||
.arg(roleColor)
|
|
||||||
.arg(htmlEscaped(role))
|
|
||||||
.arg(contentColor)
|
|
||||||
.arg(htmlEscaped(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
QString fileTypeText(const WorkspaceFileInfo &file)
|
|
||||||
{
|
|
||||||
if (file.isDirectory)
|
|
||||||
{
|
|
||||||
return QStringLiteral("目录");
|
|
||||||
}
|
|
||||||
if (file.isTextFile)
|
|
||||||
{
|
|
||||||
return QStringLiteral("文本文件");
|
|
||||||
}
|
|
||||||
return QStringLiteral("文件");
|
|
||||||
}
|
|
||||||
|
|
||||||
QString compactPath(const WorkspaceFileInfo &file)
|
|
||||||
{
|
|
||||||
return file.relativePath + (file.isDirectory ? QStringLiteral(" [目录]") : QString());
|
|
||||||
}
|
|
||||||
|
|
||||||
QFrame *panelFrame(QWidget *parent)
|
|
||||||
{
|
|
||||||
auto *frame = new QFrame(parent);
|
|
||||||
frame->setObjectName(QStringLiteral("AgentPanel"));
|
|
||||||
auto *layout = new QVBoxLayout(frame);
|
|
||||||
layout->setContentsMargins(12, 12, 12, 12);
|
|
||||||
layout->setSpacing(8);
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
QLabel *sectionTitle(const QString &text, QWidget *parent)
|
|
||||||
{
|
|
||||||
auto *label = new QLabel(text, parent);
|
|
||||||
label->setObjectName(QStringLiteral("SectionTitle"));
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
QTextEdit *readOnlyTextEdit(QWidget *parent)
|
|
||||||
{
|
|
||||||
auto *edit = new QTextEdit(parent);
|
|
||||||
edit->setReadOnly(true);
|
|
||||||
edit->setFrameShape(QFrame::NoFrame);
|
|
||||||
edit->setLineWrapMode(QTextEdit::WidgetWidth);
|
|
||||||
return edit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceAgentWindow::WorkspaceAgentWindow(QWidget *parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
{
|
|
||||||
setWindowTitle(QStringLiteral("本地工作区 Agent"));
|
|
||||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint);
|
|
||||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
|
||||||
resize(DefaultWindowWidth, DefaultWindowHeight);
|
|
||||||
setMinimumSize(MinWindowWidth, MinWindowHeight);
|
|
||||||
buildUi();
|
|
||||||
applyStyles();
|
|
||||||
updateSummary();
|
|
||||||
updateResults();
|
|
||||||
updateTextMatches();
|
|
||||||
updateContextFiles();
|
|
||||||
updateInspector();
|
|
||||||
updateActionButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setSnapshot(const WorkspacePanelSnapshot &snapshot)
|
|
||||||
{
|
|
||||||
m_snapshot = snapshot;
|
|
||||||
updateSummary();
|
|
||||||
updateResults();
|
|
||||||
updateTextMatches();
|
|
||||||
updateContextFiles();
|
|
||||||
updateInspector();
|
|
||||||
updateActionButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::showWindow()
|
|
||||||
{
|
|
||||||
if (isMinimized())
|
|
||||||
{
|
|
||||||
setWindowState(windowState() & ~Qt::WindowMinimized);
|
|
||||||
}
|
|
||||||
show();
|
|
||||||
raise();
|
|
||||||
activateWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::appendUserTask(const QString &message)
|
|
||||||
{
|
|
||||||
if (message.trimmed().isEmpty())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_chatView->append(chatBubbleHtml(
|
|
||||||
QStringLiteral("用户"),
|
|
||||||
message.trimmed(),
|
|
||||||
Qt::AlignLeft,
|
|
||||||
QStringLiteral("#172554"),
|
|
||||||
QStringLiteral("#2856b6"),
|
|
||||||
QStringLiteral("#93c5fd"),
|
|
||||||
QStringLiteral("#e0f2fe")));
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::appendAgentMessage(const QString &title, const QString &content, bool error)
|
|
||||||
{
|
|
||||||
const QString safeTitle = title.trimmed().isEmpty() ? QStringLiteral("Agent") : title.trimmed();
|
|
||||||
const QString safeContent = content.trimmed().isEmpty() ? QStringLiteral("无详细内容。") : content.trimmed();
|
|
||||||
const QString titleColor = error ? QStringLiteral("#fecaca") : QStringLiteral("#bbf7d0");
|
|
||||||
const QString borderColor = error ? QStringLiteral("#7f1d1d") : QStringLiteral("#166534");
|
|
||||||
const QString backgroundColor = error ? QStringLiteral("#2a1218") : QStringLiteral("#10251a");
|
|
||||||
m_chatView->append(chatBubbleHtml(
|
|
||||||
QStringLiteral("桌宠 · %1").arg(safeTitle),
|
|
||||||
safeContent,
|
|
||||||
Qt::AlignRight,
|
|
||||||
backgroundColor,
|
|
||||||
borderColor,
|
|
||||||
titleColor,
|
|
||||||
QStringLiteral("#dbe4f0")));
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setStatusText(const QString &status)
|
|
||||||
{
|
|
||||||
if (m_statusLabel != nullptr)
|
|
||||||
{
|
|
||||||
m_statusLabel->setText(status.trimmed().isEmpty() ? QStringLiteral("就绪") : status.trimmed());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setMessageCallback(MessageCallback callback)
|
|
||||||
{
|
|
||||||
m_messageCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setChooseWorkspaceCallback(SimpleCallback callback)
|
|
||||||
{
|
|
||||||
m_chooseWorkspaceCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setRescanCallback(SimpleCallback callback)
|
|
||||||
{
|
|
||||||
m_rescanCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setReadCallback(ResultActionCallback callback)
|
|
||||||
{
|
|
||||||
m_readCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setOpenCallback(ResultActionCallback callback)
|
|
||||||
{
|
|
||||||
m_openCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::setRevealCallback(ResultActionCallback callback)
|
|
||||||
{
|
|
||||||
m_revealCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::closeEvent(QCloseEvent *event)
|
|
||||||
{
|
|
||||||
hide();
|
|
||||||
event->ignore();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::mousePressEvent(QMouseEvent *event)
|
|
||||||
{
|
|
||||||
if (event->button() == Qt::LeftButton && event->position().y() <= 58)
|
|
||||||
{
|
|
||||||
m_dragging = true;
|
|
||||||
m_dragStartPosition = event->globalPosition().toPoint();
|
|
||||||
m_dragWindowPosition = pos();
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QWidget::mousePressEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::mouseMoveEvent(QMouseEvent *event)
|
|
||||||
{
|
|
||||||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
|
||||||
{
|
|
||||||
move(m_dragWindowPosition + (event->globalPosition().toPoint() - m_dragStartPosition));
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QWidget::mouseMoveEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::mouseReleaseEvent(QMouseEvent *event)
|
|
||||||
{
|
|
||||||
m_dragging = false;
|
|
||||||
QWidget::mouseReleaseEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::buildUi()
|
|
||||||
{
|
|
||||||
m_workspaceLabel = new QLabel(this);
|
|
||||||
m_scanLabel = new QLabel(this);
|
|
||||||
m_statusLabel = new QLabel(QStringLiteral("就绪"), this);
|
|
||||||
m_chooseWorkspaceButton = new QPushButton(QStringLiteral("选择工作区"), this);
|
|
||||||
m_rescanButton = new QPushButton(QStringLiteral("重新扫描"), this);
|
|
||||||
m_minimizeButton = new QPushButton(QStringLiteral("−"), this);
|
|
||||||
m_closeButton = new QPushButton(QStringLiteral("×"), this);
|
|
||||||
m_resultList = new QListWidget(this);
|
|
||||||
m_textMatchList = new QListWidget(this);
|
|
||||||
m_contextList = new QListWidget(this);
|
|
||||||
m_chatView = readOnlyTextEdit(this);
|
|
||||||
m_selectedDetailEdit = readOnlyTextEdit(this);
|
|
||||||
m_textMatchDetailEdit = readOnlyTextEdit(this);
|
|
||||||
m_recentReadEdit = readOnlyTextEdit(this);
|
|
||||||
m_planEdit = readOnlyTextEdit(this);
|
|
||||||
m_inputEdit = new QLineEdit(this);
|
|
||||||
m_sendButton = new QPushButton(QStringLiteral("发送"), this);
|
|
||||||
m_readButton = new QPushButton(QStringLiteral("读取"), this);
|
|
||||||
m_openButton = new QPushButton(QStringLiteral("打开"), this);
|
|
||||||
m_revealButton = new QPushButton(QStringLiteral("位置"), this);
|
|
||||||
|
|
||||||
m_workspaceLabel->setObjectName(QStringLiteral("WorkspaceTitle"));
|
|
||||||
m_workspaceLabel->setWordWrap(true);
|
|
||||||
m_scanLabel->setObjectName(QStringLiteral("MetaLabel"));
|
|
||||||
m_statusLabel->setObjectName(QStringLiteral("StatusPill"));
|
|
||||||
m_minimizeButton->setObjectName(QStringLiteral("WindowButton"));
|
|
||||||
m_closeButton->setObjectName(QStringLiteral("CloseButton"));
|
|
||||||
m_minimizeButton->setFixedSize(32, 32);
|
|
||||||
m_closeButton->setFixedSize(32, 32);
|
|
||||||
m_resultList->setObjectName(QStringLiteral("SidebarList"));
|
|
||||||
m_textMatchList->setObjectName(QStringLiteral("SidebarList"));
|
|
||||||
m_contextList->setObjectName(QStringLiteral("SidebarList"));
|
|
||||||
m_resultList->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
||||||
m_textMatchList->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
||||||
m_contextList->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
||||||
m_chatView->setObjectName(QStringLiteral("ChatView"));
|
|
||||||
m_inputEdit->setObjectName(QStringLiteral("TaskInput"));
|
|
||||||
m_inputEdit->setPlaceholderText(QStringLiteral("输入工作区任务,例如:含 class 的文件 / 读取 main.cpp / 分析这个项目"));
|
|
||||||
|
|
||||||
auto *titleTextLayout = new QVBoxLayout;
|
|
||||||
titleTextLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
titleTextLayout->setSpacing(4);
|
|
||||||
titleTextLayout->addWidget(m_workspaceLabel);
|
|
||||||
titleTextLayout->addWidget(m_scanLabel);
|
|
||||||
|
|
||||||
auto *topBar = new QHBoxLayout;
|
|
||||||
topBar->setContentsMargins(18, 14, 18, 8);
|
|
||||||
topBar->setSpacing(10);
|
|
||||||
topBar->addLayout(titleTextLayout, 1);
|
|
||||||
topBar->addWidget(m_statusLabel);
|
|
||||||
topBar->addWidget(m_chooseWorkspaceButton);
|
|
||||||
topBar->addWidget(m_rescanButton);
|
|
||||||
topBar->addWidget(m_minimizeButton);
|
|
||||||
topBar->addWidget(m_closeButton);
|
|
||||||
|
|
||||||
auto *fileFrame = panelFrame(this);
|
|
||||||
fileFrame->layout()->addWidget(sectionTitle(QStringLiteral("文件结果"), fileFrame));
|
|
||||||
fileFrame->layout()->addWidget(m_resultList);
|
|
||||||
|
|
||||||
auto *textFrame = panelFrame(this);
|
|
||||||
textFrame->layout()->addWidget(sectionTitle(QStringLiteral("文本命中"), textFrame));
|
|
||||||
textFrame->layout()->addWidget(m_textMatchList);
|
|
||||||
|
|
||||||
auto *contextFrame = panelFrame(this);
|
|
||||||
contextFrame->layout()->addWidget(sectionTitle(QStringLiteral("上下文"), contextFrame));
|
|
||||||
contextFrame->layout()->addWidget(m_contextList);
|
|
||||||
|
|
||||||
auto *sidebarLayout = new QVBoxLayout;
|
|
||||||
sidebarLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
sidebarLayout->setSpacing(10);
|
|
||||||
sidebarLayout->addWidget(fileFrame, 3);
|
|
||||||
sidebarLayout->addWidget(textFrame, 2);
|
|
||||||
sidebarLayout->addWidget(contextFrame, 1);
|
|
||||||
auto *sidebar = new QWidget(this);
|
|
||||||
sidebar->setLayout(sidebarLayout);
|
|
||||||
|
|
||||||
auto *chatFrame = panelFrame(this);
|
|
||||||
chatFrame->layout()->addWidget(sectionTitle(QStringLiteral("Agent 对话"), chatFrame));
|
|
||||||
chatFrame->layout()->addWidget(m_chatView);
|
|
||||||
|
|
||||||
auto *inputFrame = new QFrame(this);
|
|
||||||
inputFrame->setObjectName(QStringLiteral("InputPanel"));
|
|
||||||
auto *inputLayout = new QHBoxLayout(inputFrame);
|
|
||||||
inputLayout->setContentsMargins(12, 10, 12, 10);
|
|
||||||
inputLayout->setSpacing(10);
|
|
||||||
inputLayout->addWidget(m_inputEdit, 1);
|
|
||||||
inputLayout->addWidget(m_sendButton);
|
|
||||||
|
|
||||||
auto *centerLayout = new QVBoxLayout;
|
|
||||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
centerLayout->setSpacing(10);
|
|
||||||
centerLayout->addWidget(chatFrame, 1);
|
|
||||||
centerLayout->addWidget(inputFrame);
|
|
||||||
auto *center = new QWidget(this);
|
|
||||||
center->setLayout(centerLayout);
|
|
||||||
|
|
||||||
auto *selectedFrame = panelFrame(this);
|
|
||||||
selectedFrame->layout()->addWidget(sectionTitle(QStringLiteral("选中项详情"), selectedFrame));
|
|
||||||
selectedFrame->layout()->addWidget(m_selectedDetailEdit);
|
|
||||||
|
|
||||||
auto *textMatchDetailFrame = panelFrame(this);
|
|
||||||
textMatchDetailFrame->layout()->addWidget(sectionTitle(QStringLiteral("文本命中详情"), textMatchDetailFrame));
|
|
||||||
textMatchDetailFrame->layout()->addWidget(m_textMatchDetailEdit);
|
|
||||||
|
|
||||||
auto *recentReadFrame = panelFrame(this);
|
|
||||||
recentReadFrame->layout()->addWidget(sectionTitle(QStringLiteral("最近读取预览"), recentReadFrame));
|
|
||||||
recentReadFrame->layout()->addWidget(m_recentReadEdit);
|
|
||||||
|
|
||||||
auto *planFrame = panelFrame(this);
|
|
||||||
planFrame->layout()->addWidget(sectionTitle(QStringLiteral("变更计划 / Diff"), planFrame));
|
|
||||||
planFrame->layout()->addWidget(m_planEdit);
|
|
||||||
|
|
||||||
auto *actionLayout = new QHBoxLayout;
|
|
||||||
actionLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
actionLayout->setSpacing(8);
|
|
||||||
actionLayout->addWidget(m_readButton);
|
|
||||||
actionLayout->addWidget(m_openButton);
|
|
||||||
actionLayout->addWidget(m_revealButton);
|
|
||||||
|
|
||||||
auto *inspectorLayout = new QVBoxLayout;
|
|
||||||
inspectorLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
inspectorLayout->setSpacing(10);
|
|
||||||
inspectorLayout->addWidget(selectedFrame, 2);
|
|
||||||
inspectorLayout->addWidget(textMatchDetailFrame, 2);
|
|
||||||
inspectorLayout->addWidget(recentReadFrame, 3);
|
|
||||||
inspectorLayout->addWidget(planFrame, 2);
|
|
||||||
inspectorLayout->addLayout(actionLayout);
|
|
||||||
auto *inspector = new QWidget(this);
|
|
||||||
inspector->setLayout(inspectorLayout);
|
|
||||||
|
|
||||||
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
|
||||||
splitter->setObjectName(QStringLiteral("AgentSplitter"));
|
|
||||||
splitter->addWidget(sidebar);
|
|
||||||
splitter->addWidget(center);
|
|
||||||
splitter->addWidget(inspector);
|
|
||||||
splitter->setStretchFactor(0, 0);
|
|
||||||
splitter->setStretchFactor(1, 1);
|
|
||||||
splitter->setStretchFactor(2, 0);
|
|
||||||
splitter->setSizes({290, 560, 330});
|
|
||||||
|
|
||||||
auto *root = new QVBoxLayout(this);
|
|
||||||
root->setContentsMargins(0, 0, 0, 0);
|
|
||||||
root->setSpacing(0);
|
|
||||||
root->addLayout(topBar);
|
|
||||||
root->addWidget(splitter, 1);
|
|
||||||
|
|
||||||
connect(m_chooseWorkspaceButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
if (m_chooseWorkspaceCallback)
|
|
||||||
{
|
|
||||||
m_chooseWorkspaceCallback();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_rescanButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
if (m_rescanCallback)
|
|
||||||
{
|
|
||||||
m_rescanCallback();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_minimizeButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
showMinimized();
|
|
||||||
});
|
|
||||||
connect(m_closeButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
hide();
|
|
||||||
});
|
|
||||||
connect(m_sendButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
submitInput();
|
|
||||||
});
|
|
||||||
connect(m_inputEdit, &QLineEdit::returnPressed, this, [this]() {
|
|
||||||
submitInput();
|
|
||||||
});
|
|
||||||
connect(m_resultList, &QListWidget::currentRowChanged, this, [this]() {
|
|
||||||
updateInspector();
|
|
||||||
updateActionButtons();
|
|
||||||
});
|
|
||||||
connect(m_textMatchList, &QListWidget::currentRowChanged, this, [this]() {
|
|
||||||
updateInspector();
|
|
||||||
});
|
|
||||||
connect(m_readButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index >= 0 && m_readCallback)
|
|
||||||
{
|
|
||||||
m_readCallback(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_openButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index >= 0 && m_openCallback)
|
|
||||||
{
|
|
||||||
m_openCallback(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_revealButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index >= 0 && m_revealCallback)
|
|
||||||
{
|
|
||||||
m_revealCallback(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::applyStyles()
|
|
||||||
{
|
|
||||||
setStyleSheet(QStringLiteral(
|
|
||||||
"QWidget { background:#0b1120; color:#e5e7eb; font-family:'Microsoft YaHei', 'Segoe UI'; }"
|
|
||||||
"QLabel#WorkspaceTitle { color:#f8fafc; font-size:17px; font-weight:700; }"
|
|
||||||
"QLabel#MetaLabel { color:#94a3b8; font-size:12px; }"
|
|
||||||
"QLabel#StatusPill { background:#102a43; border:1px solid #1e3a5f; border-radius:12px; color:#bfdbfe; padding:4px 10px; }"
|
|
||||||
"QLabel#SectionTitle { color:#bfdbfe; font-size:13px; font-weight:700; }"
|
|
||||||
"QFrame#AgentPanel, QFrame#InputPanel { background:#111827; border:1px solid #26364f; border-radius:8px; }"
|
|
||||||
"QListWidget#SidebarList { background:#0f172a; border:1px solid #1f2f46; border-radius:6px; color:#dbe4f0; padding:4px; }"
|
|
||||||
"QListWidget#SidebarList::item { min-height:24px; padding:5px 7px; border-radius:5px; }"
|
|
||||||
"QListWidget#SidebarList::item:selected { background:#1d4ed8; color:#ffffff; }"
|
|
||||||
"QTextEdit { background:#0f172a; border:1px solid #1f2f46; border-radius:6px; color:#dbe4f0; padding:8px; selection-background-color:#2563eb; }"
|
|
||||||
"QTextEdit#ChatView { font-size:13px; padding:12px; }"
|
|
||||||
"QLineEdit#TaskInput { background:#0f172a; border:1px solid #334155; border-radius:7px; color:#f8fafc; padding:8px 10px; min-height:28px; }"
|
|
||||||
"QLineEdit#TaskInput:focus { border-color:#3b82f6; }"
|
|
||||||
"QPushButton { background:#1f2937; border:1px solid #334155; border-radius:7px; color:#e5e7eb; padding:6px 12px; min-height:28px; }"
|
|
||||||
"QPushButton:hover:enabled { background:#26364f; border-color:#475569; }"
|
|
||||||
"QPushButton:disabled { background:#111827; border-color:#1f2937; color:#64748b; }"
|
|
||||||
"QPushButton#WindowButton, QPushButton#CloseButton { min-width:32px; max-width:32px; min-height:32px; max-height:32px; border-radius:16px; padding:0; font-size:18px; }"
|
|
||||||
"QPushButton#CloseButton:hover:enabled { background:#7f1d1d; border-color:#991b1b; }"
|
|
||||||
"QSplitter::handle { background:#0b1120; width:8px; }"));
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::updateSummary()
|
|
||||||
{
|
|
||||||
const QString workspaceText = m_snapshot.workspaceRoot.trimmed().isEmpty()
|
|
||||||
? QStringLiteral("未选择工作区")
|
|
||||||
: m_snapshot.workspaceRoot;
|
|
||||||
const QString scanText = m_snapshot.lastScanAt.isValid()
|
|
||||||
? m_snapshot.lastScanAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))
|
|
||||||
: QStringLiteral("尚未扫描");
|
|
||||||
m_workspaceLabel->setText(QStringLiteral("本地工作区 Agent"));
|
|
||||||
m_scanLabel->setText(QStringLiteral("工作区:%1 索引项:%2 最近扫描:%3")
|
|
||||||
.arg(workspaceText)
|
|
||||||
.arg(m_snapshot.indexedItemCount)
|
|
||||||
.arg(scanText));
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::updateResults()
|
|
||||||
{
|
|
||||||
const int currentRow = m_resultList->currentRow();
|
|
||||||
m_resultList->clear();
|
|
||||||
if (m_snapshot.lastResults.isEmpty())
|
|
||||||
{
|
|
||||||
auto *item = new QListWidgetItem(QStringLiteral("暂无文件结果"));
|
|
||||||
item->setFlags(Qt::NoItemFlags);
|
|
||||||
m_resultList->addItem(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int index = 0; index < m_snapshot.lastResults.size(); ++index)
|
|
||||||
{
|
|
||||||
m_resultList->addItem(QStringLiteral("%1. %2").arg(index + 1).arg(compactPath(m_snapshot.lastResults.at(index))));
|
|
||||||
}
|
|
||||||
if (currentRow >= 0 && currentRow < m_resultList->count())
|
|
||||||
{
|
|
||||||
m_resultList->setCurrentRow(currentRow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
m_resultList->setCurrentRow(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::updateTextMatches()
|
|
||||||
{
|
|
||||||
const int currentRow = m_textMatchList->currentRow();
|
|
||||||
m_textMatchList->clear();
|
|
||||||
if (m_snapshot.lastTextMatches.isEmpty())
|
|
||||||
{
|
|
||||||
auto *item = new QListWidgetItem(QStringLiteral("暂无文本命中"));
|
|
||||||
item->setFlags(Qt::NoItemFlags);
|
|
||||||
m_textMatchList->addItem(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInSidebar);
|
|
||||||
for (int i = 0; i < matchCount; ++i)
|
|
||||||
{
|
|
||||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
|
|
||||||
m_textMatchList->addItem(QStringLiteral("%1:%2 %3")
|
|
||||||
.arg(match.relativePath)
|
|
||||||
.arg(match.lineNumber)
|
|
||||||
.arg(match.lineText.simplified().left(80)));
|
|
||||||
}
|
|
||||||
if (m_snapshot.lastTextMatches.size() > matchCount)
|
|
||||||
{
|
|
||||||
auto *item = new QListWidgetItem(QStringLiteral("仅显示前 %1 条命中").arg(matchCount));
|
|
||||||
item->setFlags(Qt::NoItemFlags);
|
|
||||||
m_textMatchList->addItem(item);
|
|
||||||
}
|
|
||||||
if (currentRow >= 0 && currentRow < m_textMatchList->count())
|
|
||||||
{
|
|
||||||
m_textMatchList->setCurrentRow(currentRow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::updateContextFiles()
|
|
||||||
{
|
|
||||||
m_contextList->clear();
|
|
||||||
if (!m_snapshot.hasLastReadFile)
|
|
||||||
{
|
|
||||||
auto *item = new QListWidgetItem(QStringLiteral("暂无最近读取文件"));
|
|
||||||
item->setFlags(Qt::NoItemFlags);
|
|
||||||
m_contextList->addItem(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_contextList->addItem(m_snapshot.lastReadFile.relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::updateInspector()
|
|
||||||
{
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index < 0)
|
|
||||||
{
|
|
||||||
m_selectedDetailEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">请选择左侧文件结果。</div>"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index);
|
|
||||||
m_selectedDetailEdit->setHtml(QStringLiteral(
|
|
||||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
|
||||||
"<div style=\"color:#cbd5e1;\">类型:%2<br>大小:%3 字节<br>修改时间:%4<br>绝对路径:%5</div>")
|
|
||||||
.arg(htmlEscaped(compactPath(file)))
|
|
||||||
.arg(fileTypeText(file))
|
|
||||||
.arg(file.sizeBytes)
|
|
||||||
.arg(file.lastModified.isValid() ? file.lastModified.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("未知"))
|
|
||||||
.arg(htmlEscaped(file.absolutePath)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_snapshot.hasLastReadFile)
|
|
||||||
{
|
|
||||||
m_recentReadEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">暂无最近读取文件。</div>"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
m_recentReadEdit->setHtml(QStringLiteral(
|
|
||||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
|
||||||
"<pre style=\"white-space:pre-wrap; color:#dbe4f0; font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px; margin:0;\">%2</pre>")
|
|
||||||
.arg(htmlEscaped(m_snapshot.lastReadFile.relativePath))
|
|
||||||
.arg(m_snapshot.lastReadPreview.toHtmlEscaped()));
|
|
||||||
}
|
|
||||||
|
|
||||||
const int matchRow = m_textMatchList->currentRow();
|
|
||||||
if (matchRow >= 0 && matchRow < m_snapshot.lastTextMatches.size())
|
|
||||||
{
|
|
||||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(matchRow);
|
|
||||||
m_textMatchDetailEdit->setHtml(QStringLiteral(
|
|
||||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1:%2</div>"
|
|
||||||
"<div style=\"color:#dbe4f0; white-space:pre-wrap;\">%3</div>"
|
|
||||||
"<div style=\"margin-top:8px; color:#94a3b8;\">%4</div>")
|
|
||||||
.arg(htmlEscaped(match.relativePath))
|
|
||||||
.arg(match.lineNumber)
|
|
||||||
.arg(htmlEscaped(match.lineText))
|
|
||||||
.arg(htmlEscaped(match.absolutePath)));
|
|
||||||
}
|
|
||||||
else if (m_snapshot.lastTextMatches.isEmpty())
|
|
||||||
{
|
|
||||||
m_textMatchDetailEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">暂无文本搜索命中。</div>"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
QString matchHtml;
|
|
||||||
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInInspector);
|
|
||||||
for (int i = 0; i < matchCount; ++i)
|
|
||||||
{
|
|
||||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
|
|
||||||
matchHtml += QStringLiteral("<div style=\"margin-bottom:8px;\"><b style=\"color:#bfdbfe;\">%1:%2</b><br>%3</div>")
|
|
||||||
.arg(htmlEscaped(match.relativePath))
|
|
||||||
.arg(match.lineNumber)
|
|
||||||
.arg(htmlEscaped(match.lineText));
|
|
||||||
}
|
|
||||||
m_textMatchDetailEdit->setHtml(matchHtml);
|
|
||||||
}
|
|
||||||
m_planEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">v1b 阶段保持只读,没有待确认变更。v1c 写操作会在这里展示计划和 Diff。</div>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::updateActionButtons()
|
|
||||||
{
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
const bool hasSelection = index >= 0;
|
|
||||||
m_openButton->setEnabled(hasSelection);
|
|
||||||
m_revealButton->setEnabled(hasSelection);
|
|
||||||
m_readButton->setEnabled(hasSelection && !m_snapshot.lastResults.at(index).isDirectory);
|
|
||||||
m_rescanButton->setEnabled(!m_snapshot.workspaceRoot.trimmed().isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceAgentWindow::submitInput()
|
|
||||||
{
|
|
||||||
const QString message = m_inputEdit->text().trimmed();
|
|
||||||
if (message.isEmpty())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_messageCallback && m_messageCallback(message))
|
|
||||||
{
|
|
||||||
m_inputEdit->clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int WorkspaceAgentWindow::selectedResultIndex() const
|
|
||||||
{
|
|
||||||
const int row = m_resultList->currentRow();
|
|
||||||
if (row < 0 || row >= m_snapshot.lastResults.size())
|
|
||||||
{
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../workspace/WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QPoint>
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
class QLabel;
|
|
||||||
class QLineEdit;
|
|
||||||
class QListWidget;
|
|
||||||
class QPushButton;
|
|
||||||
class QTextEdit;
|
|
||||||
class QCloseEvent;
|
|
||||||
class QMouseEvent;
|
|
||||||
|
|
||||||
class WorkspaceAgentWindow : public QWidget
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
using MessageCallback = std::function<bool(const QString &)>;
|
|
||||||
using SimpleCallback = std::function<void()>;
|
|
||||||
using ResultActionCallback = std::function<void(int)>;
|
|
||||||
|
|
||||||
explicit WorkspaceAgentWindow(QWidget *parent = nullptr);
|
|
||||||
|
|
||||||
void setSnapshot(const WorkspacePanelSnapshot &snapshot);
|
|
||||||
void showWindow();
|
|
||||||
void appendUserTask(const QString &message);
|
|
||||||
void appendAgentMessage(const QString &title, const QString &content, bool error = false);
|
|
||||||
void setStatusText(const QString &status);
|
|
||||||
|
|
||||||
void setMessageCallback(MessageCallback callback);
|
|
||||||
void setChooseWorkspaceCallback(SimpleCallback callback);
|
|
||||||
void setRescanCallback(SimpleCallback callback);
|
|
||||||
void setReadCallback(ResultActionCallback callback);
|
|
||||||
void setOpenCallback(ResultActionCallback callback);
|
|
||||||
void setRevealCallback(ResultActionCallback callback);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void closeEvent(QCloseEvent *event) override;
|
|
||||||
void mouseMoveEvent(QMouseEvent *event) override;
|
|
||||||
void mousePressEvent(QMouseEvent *event) override;
|
|
||||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
void buildUi();
|
|
||||||
void applyStyles();
|
|
||||||
void updateSummary();
|
|
||||||
void updateResults();
|
|
||||||
void updateTextMatches();
|
|
||||||
void updateContextFiles();
|
|
||||||
void updateInspector();
|
|
||||||
void updateActionButtons();
|
|
||||||
void submitInput();
|
|
||||||
int selectedResultIndex() const;
|
|
||||||
|
|
||||||
QLabel *m_workspaceLabel = nullptr;
|
|
||||||
QLabel *m_scanLabel = nullptr;
|
|
||||||
QLabel *m_statusLabel = nullptr;
|
|
||||||
QPushButton *m_chooseWorkspaceButton = nullptr;
|
|
||||||
QPushButton *m_rescanButton = nullptr;
|
|
||||||
QPushButton *m_minimizeButton = nullptr;
|
|
||||||
QPushButton *m_closeButton = nullptr;
|
|
||||||
QListWidget *m_resultList = nullptr;
|
|
||||||
QListWidget *m_textMatchList = nullptr;
|
|
||||||
QListWidget *m_contextList = nullptr;
|
|
||||||
QTextEdit *m_chatView = nullptr;
|
|
||||||
QTextEdit *m_selectedDetailEdit = nullptr;
|
|
||||||
QTextEdit *m_textMatchDetailEdit = nullptr;
|
|
||||||
QTextEdit *m_recentReadEdit = nullptr;
|
|
||||||
QTextEdit *m_planEdit = nullptr;
|
|
||||||
QLineEdit *m_inputEdit = nullptr;
|
|
||||||
QPushButton *m_sendButton = nullptr;
|
|
||||||
QPushButton *m_readButton = nullptr;
|
|
||||||
QPushButton *m_openButton = nullptr;
|
|
||||||
QPushButton *m_revealButton = nullptr;
|
|
||||||
|
|
||||||
WorkspacePanelSnapshot m_snapshot;
|
|
||||||
MessageCallback m_messageCallback;
|
|
||||||
SimpleCallback m_chooseWorkspaceCallback;
|
|
||||||
SimpleCallback m_rescanCallback;
|
|
||||||
ResultActionCallback m_readCallback;
|
|
||||||
ResultActionCallback m_openCallback;
|
|
||||||
ResultActionCallback m_revealCallback;
|
|
||||||
QPoint m_dragStartPosition;
|
|
||||||
QPoint m_dragWindowPosition;
|
|
||||||
bool m_dragging = false;
|
|
||||||
};
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
#include "WorkspacePanel.h"
|
|
||||||
|
|
||||||
#include <QAbstractItemView>
|
|
||||||
#include <QFrame>
|
|
||||||
#include <QGuiApplication>
|
|
||||||
#include <QHBoxLayout>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QListWidget>
|
|
||||||
#include <QListWidgetItem>
|
|
||||||
#include <QMouseEvent>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QScreen>
|
|
||||||
#include <QSplitter>
|
|
||||||
#include <QTextEdit>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
#include <limits>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr int PanelWidth = 780;
|
|
||||||
constexpr int PanelHeight = 560;
|
|
||||||
constexpr int PanelGap = 16;
|
|
||||||
constexpr int ScreenMargin = 8;
|
|
||||||
constexpr int MaxTextMatchesInDetails = 30;
|
|
||||||
|
|
||||||
int boundedCoordinate(int value, int minimum, int maximum)
|
|
||||||
{
|
|
||||||
if (maximum < minimum)
|
|
||||||
{
|
|
||||||
return minimum;
|
|
||||||
}
|
|
||||||
return qBound(minimum, value, maximum);
|
|
||||||
}
|
|
||||||
|
|
||||||
qint64 overlapArea(const QRect &first, const QRect &second)
|
|
||||||
{
|
|
||||||
const QRect intersection = first.intersected(second);
|
|
||||||
if (intersection.isEmpty())
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return static_cast<qint64>(intersection.width()) * static_cast<qint64>(intersection.height());
|
|
||||||
}
|
|
||||||
|
|
||||||
qint64 squaredDistance(const QPoint &first, const QPoint &second)
|
|
||||||
{
|
|
||||||
const qint64 dx = first.x() - second.x();
|
|
||||||
const qint64 dy = first.y() - second.y();
|
|
||||||
return dx * dx + dy * dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString compactPath(const WorkspaceFileInfo &file)
|
|
||||||
{
|
|
||||||
return file.relativePath + (file.isDirectory ? QStringLiteral(" [目录]") : QString());
|
|
||||||
}
|
|
||||||
|
|
||||||
QString fileTypeText(const WorkspaceFileInfo &file)
|
|
||||||
{
|
|
||||||
if (file.isDirectory)
|
|
||||||
{
|
|
||||||
return QStringLiteral("目录");
|
|
||||||
}
|
|
||||||
if (file.isTextFile)
|
|
||||||
{
|
|
||||||
return QStringLiteral("文本文件");
|
|
||||||
}
|
|
||||||
return QStringLiteral("文件");
|
|
||||||
}
|
|
||||||
|
|
||||||
QString htmlLine(QString text)
|
|
||||||
{
|
|
||||||
return text.toHtmlEscaped().replace(QStringLiteral("\n"), QStringLiteral("<br>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
QFrame *card(QWidget *parent)
|
|
||||||
{
|
|
||||||
auto *frame = new QFrame(parent);
|
|
||||||
frame->setObjectName(QStringLiteral("WorkspaceCard"));
|
|
||||||
auto *layout = new QVBoxLayout(frame);
|
|
||||||
layout->setContentsMargins(14, 12, 14, 12);
|
|
||||||
layout->setSpacing(8);
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
QLabel *cardTitle(const QString &text, QWidget *parent)
|
|
||||||
{
|
|
||||||
auto *label = new QLabel(text, parent);
|
|
||||||
label->setObjectName(QStringLiteral("CardTitle"));
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
QTextEdit *readOnlyTextEdit(QWidget *parent)
|
|
||||||
{
|
|
||||||
auto *edit = new QTextEdit(parent);
|
|
||||||
edit->setReadOnly(true);
|
|
||||||
edit->setFrameShape(QFrame::NoFrame);
|
|
||||||
edit->setLineWrapMode(QTextEdit::WidgetWidth);
|
|
||||||
return edit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspacePanel::WorkspacePanel(QWidget *parent)
|
|
||||||
: QDialog(parent)
|
|
||||||
, m_workspaceLabel(new QLabel(this))
|
|
||||||
, m_scanLabel(new QLabel(this))
|
|
||||||
, m_summaryLabel(new QLabel(this))
|
|
||||||
, m_resultList(new QListWidget(this))
|
|
||||||
, m_selectedDetailEdit(readOnlyTextEdit(this))
|
|
||||||
, m_textMatchesEdit(readOnlyTextEdit(this))
|
|
||||||
, m_recentReadEdit(readOnlyTextEdit(this))
|
|
||||||
, m_readButton(new QPushButton(QStringLiteral("读取"), this))
|
|
||||||
, m_openButton(new QPushButton(QStringLiteral("打开"), this))
|
|
||||||
, m_revealButton(new QPushButton(QStringLiteral("位置"), this))
|
|
||||||
, m_closeButton(new QPushButton(QStringLiteral("×"), this))
|
|
||||||
{
|
|
||||||
setWindowTitle(QStringLiteral("本地工作区 Agent"));
|
|
||||||
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
|
||||||
setModal(false);
|
|
||||||
resize(PanelWidth, PanelHeight);
|
|
||||||
|
|
||||||
m_workspaceLabel->setObjectName(QStringLiteral("WorkspaceTitle"));
|
|
||||||
m_workspaceLabel->setWordWrap(true);
|
|
||||||
m_scanLabel->setObjectName(QStringLiteral("MetaLabel"));
|
|
||||||
m_summaryLabel->setObjectName(QStringLiteral("MetaLabel"));
|
|
||||||
m_summaryLabel->setWordWrap(true);
|
|
||||||
m_closeButton->setObjectName(QStringLiteral("TitleCloseButton"));
|
|
||||||
m_closeButton->setFixedSize(30, 30);
|
|
||||||
m_resultList->setObjectName(QStringLiteral("ResultList"));
|
|
||||||
m_resultList->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
||||||
|
|
||||||
auto *titleLayout = new QHBoxLayout;
|
|
||||||
titleLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
titleLayout->setSpacing(10);
|
|
||||||
titleLayout->addWidget(m_workspaceLabel, 1);
|
|
||||||
titleLayout->addWidget(m_closeButton);
|
|
||||||
|
|
||||||
auto *headerLayout = new QVBoxLayout;
|
|
||||||
headerLayout->setSpacing(6);
|
|
||||||
headerLayout->addLayout(titleLayout);
|
|
||||||
headerLayout->addWidget(m_scanLabel);
|
|
||||||
headerLayout->addWidget(m_summaryLabel);
|
|
||||||
|
|
||||||
auto *leftCard = card(this);
|
|
||||||
leftCard->setMinimumWidth(260);
|
|
||||||
leftCard->layout()->addWidget(cardTitle(QStringLiteral("上一轮结果"), leftCard));
|
|
||||||
leftCard->layout()->addWidget(m_resultList);
|
|
||||||
|
|
||||||
auto *selectedCard = card(this);
|
|
||||||
selectedCard->layout()->addWidget(cardTitle(QStringLiteral("选中项"), selectedCard));
|
|
||||||
selectedCard->layout()->addWidget(m_selectedDetailEdit);
|
|
||||||
|
|
||||||
auto *matchesCard = card(this);
|
|
||||||
matchesCard->layout()->addWidget(cardTitle(QStringLiteral("文本命中"), matchesCard));
|
|
||||||
matchesCard->layout()->addWidget(m_textMatchesEdit);
|
|
||||||
|
|
||||||
auto *recentReadCard = card(this);
|
|
||||||
recentReadCard->layout()->addWidget(cardTitle(QStringLiteral("最近读取"), recentReadCard));
|
|
||||||
recentReadCard->layout()->addWidget(m_recentReadEdit);
|
|
||||||
|
|
||||||
auto *rightLayout = new QVBoxLayout;
|
|
||||||
rightLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
rightLayout->setSpacing(12);
|
|
||||||
rightLayout->addWidget(selectedCard, 1);
|
|
||||||
rightLayout->addWidget(matchesCard, 2);
|
|
||||||
rightLayout->addWidget(recentReadCard, 2);
|
|
||||||
|
|
||||||
auto *rightContainer = new QWidget(this);
|
|
||||||
rightContainer->setLayout(rightLayout);
|
|
||||||
|
|
||||||
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
|
||||||
splitter->setObjectName(QStringLiteral("WorkspaceSplitter"));
|
|
||||||
splitter->addWidget(leftCard);
|
|
||||||
splitter->addWidget(rightContainer);
|
|
||||||
splitter->setStretchFactor(0, 0);
|
|
||||||
splitter->setStretchFactor(1, 1);
|
|
||||||
splitter->setSizes({280, 470});
|
|
||||||
|
|
||||||
auto *buttonLayout = new QHBoxLayout;
|
|
||||||
buttonLayout->setSpacing(10);
|
|
||||||
buttonLayout->addWidget(m_readButton);
|
|
||||||
buttonLayout->addWidget(m_openButton);
|
|
||||||
buttonLayout->addWidget(m_revealButton);
|
|
||||||
buttonLayout->addStretch(1);
|
|
||||||
auto *bottomCloseButton = new QPushButton(QStringLiteral("关闭"), this);
|
|
||||||
buttonLayout->addWidget(bottomCloseButton);
|
|
||||||
|
|
||||||
auto *layout = new QVBoxLayout(this);
|
|
||||||
layout->setContentsMargins(16, 16, 16, 16);
|
|
||||||
layout->setSpacing(14);
|
|
||||||
layout->addLayout(headerLayout);
|
|
||||||
layout->addWidget(splitter, 1);
|
|
||||||
layout->addLayout(buttonLayout);
|
|
||||||
|
|
||||||
setStyleSheet(QStringLiteral(
|
|
||||||
"QDialog { background: #111827; color: #e5e7eb; }"
|
|
||||||
"QLabel#WorkspaceTitle { color: #f8fafc; font-size: 17px; font-weight: 700; }"
|
|
||||||
"QLabel#MetaLabel { color: #93a4bd; font-size: 12px; }"
|
|
||||||
"QLabel#CardTitle { color: #dbeafe; font-size: 13px; font-weight: 700; }"
|
|
||||||
"QFrame#WorkspaceCard { background: #172235; border: 1px solid #2a3a55; border-radius: 8px; }"
|
|
||||||
"QListWidget#ResultList { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 4px; }"
|
|
||||||
"QListWidget#ResultList::item { min-height: 28px; padding: 6px 8px; border-radius: 5px; }"
|
|
||||||
"QListWidget#ResultList::item:selected { background: #1d4ed8; color: #ffffff; }"
|
|
||||||
"QTextEdit { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 8px; selection-background-color: #2563eb; }"
|
|
||||||
"QPushButton {"
|
|
||||||
"background: #1f2d44;"
|
|
||||||
"border: 1px solid #35506f;"
|
|
||||||
"border-radius: 6px;"
|
|
||||||
"padding: 5px 14px;"
|
|
||||||
"min-height: 30px;"
|
|
||||||
"color: #e5e7eb;"
|
|
||||||
"}"
|
|
||||||
"QPushButton:hover:enabled { background: #263b5f; }"
|
|
||||||
"QPushButton:disabled { color: #64748b; background: #162033; border-color: #243247; }"
|
|
||||||
"QPushButton#TitleCloseButton { min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; border-radius: 15px; padding: 0; font-size: 18px; }"
|
|
||||||
"QPushButton#TitleCloseButton:hover:enabled { background: #7f1d1d; border-color: #991b1b; }"
|
|
||||||
"QSplitter::handle { background: transparent; width: 8px; }"));
|
|
||||||
|
|
||||||
connect(m_resultList, &QListWidget::currentRowChanged, this, [this]() {
|
|
||||||
updateDetails();
|
|
||||||
updateActionButtons();
|
|
||||||
});
|
|
||||||
connect(m_readButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index >= 0 && m_readCallback)
|
|
||||||
{
|
|
||||||
m_readCallback(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_openButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index >= 0 && m_openCallback)
|
|
||||||
{
|
|
||||||
m_openCallback(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_revealButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index >= 0 && m_revealCallback)
|
|
||||||
{
|
|
||||||
m_revealCallback(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connect(m_closeButton, &QPushButton::clicked, this, &QDialog::hide);
|
|
||||||
connect(bottomCloseButton, &QPushButton::clicked, this, &QDialog::hide);
|
|
||||||
updateDetails();
|
|
||||||
updateActionButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::setSnapshot(const WorkspacePanelSnapshot &snapshot)
|
|
||||||
{
|
|
||||||
m_snapshot = snapshot;
|
|
||||||
updateSummary();
|
|
||||||
updateResults();
|
|
||||||
updateDetails();
|
|
||||||
updateActionButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::showNear(const QRect &avoidRect)
|
|
||||||
{
|
|
||||||
move(smartTopLeftNear(avoidRect));
|
|
||||||
show();
|
|
||||||
raise();
|
|
||||||
activateWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::setReadCallback(ResultActionCallback callback)
|
|
||||||
{
|
|
||||||
m_readCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::setOpenCallback(ResultActionCallback callback)
|
|
||||||
{
|
|
||||||
m_openCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::setRevealCallback(ResultActionCallback callback)
|
|
||||||
{
|
|
||||||
m_revealCallback = std::move(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::mousePressEvent(QMouseEvent *event)
|
|
||||||
{
|
|
||||||
if (event->button() == Qt::LeftButton && event->position().y() <= 72)
|
|
||||||
{
|
|
||||||
m_dragging = true;
|
|
||||||
m_dragStartPosition = event->globalPosition().toPoint();
|
|
||||||
m_dragWindowPosition = pos();
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QDialog::mousePressEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::mouseMoveEvent(QMouseEvent *event)
|
|
||||||
{
|
|
||||||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
|
||||||
{
|
|
||||||
move(m_dragWindowPosition + (event->globalPosition().toPoint() - m_dragStartPosition));
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QDialog::mouseMoveEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::mouseReleaseEvent(QMouseEvent *event)
|
|
||||||
{
|
|
||||||
m_dragging = false;
|
|
||||||
QDialog::mouseReleaseEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
QRect WorkspacePanel::availableGeometryForRect(const QRect &rect) const
|
|
||||||
{
|
|
||||||
const QScreen *screen = QGuiApplication::screenAt(rect.center());
|
|
||||||
if (screen == nullptr)
|
|
||||||
{
|
|
||||||
screen = QGuiApplication::screenAt(rect.topLeft());
|
|
||||||
}
|
|
||||||
if (screen == nullptr)
|
|
||||||
{
|
|
||||||
screen = QGuiApplication::primaryScreen();
|
|
||||||
}
|
|
||||||
return screen == nullptr ? QRect() : screen->availableGeometry();
|
|
||||||
}
|
|
||||||
|
|
||||||
QPoint WorkspacePanel::constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const
|
|
||||||
{
|
|
||||||
if (!availableGeometry.isValid())
|
|
||||||
{
|
|
||||||
return preferredTopLeft;
|
|
||||||
}
|
|
||||||
|
|
||||||
return QPoint(
|
|
||||||
boundedCoordinate(preferredTopLeft.x(), availableGeometry.left() + ScreenMargin, availableGeometry.right() - width() - ScreenMargin),
|
|
||||||
boundedCoordinate(preferredTopLeft.y(), availableGeometry.top() + ScreenMargin, availableGeometry.bottom() - height() - ScreenMargin));
|
|
||||||
}
|
|
||||||
|
|
||||||
QPoint WorkspacePanel::smartTopLeftNear(const QRect &avoidRect) const
|
|
||||||
{
|
|
||||||
if (!avoidRect.isValid())
|
|
||||||
{
|
|
||||||
return pos();
|
|
||||||
}
|
|
||||||
|
|
||||||
const QRect availableGeometry = availableGeometryForRect(avoidRect);
|
|
||||||
const QVector<QPoint> preferredTopLefts = {
|
|
||||||
QPoint(avoidRect.right() + 1 + PanelGap, avoidRect.top()),
|
|
||||||
QPoint(avoidRect.left() - PanelGap - width(), avoidRect.top()),
|
|
||||||
QPoint(avoidRect.center().x() - width() / 2, avoidRect.top() - PanelGap - height()),
|
|
||||||
QPoint(avoidRect.center().x() - width() / 2, avoidRect.bottom() + 1 + PanelGap),
|
|
||||||
};
|
|
||||||
|
|
||||||
QPoint bestTopLeft = constrainedTopLeft(preferredTopLefts.first(), availableGeometry);
|
|
||||||
qint64 bestOverlap = std::numeric_limits<qint64>::max();
|
|
||||||
qint64 bestDistance = std::numeric_limits<qint64>::max();
|
|
||||||
int bestIndex = preferredTopLefts.size();
|
|
||||||
for (int index = 0; index < preferredTopLefts.size(); ++index)
|
|
||||||
{
|
|
||||||
const QPoint candidateTopLeft = constrainedTopLeft(preferredTopLefts.at(index), availableGeometry);
|
|
||||||
const QRect candidateRect(candidateTopLeft, size());
|
|
||||||
const qint64 candidateOverlap = overlapArea(candidateRect, avoidRect);
|
|
||||||
const qint64 candidateDistance = squaredDistance(candidateTopLeft, preferredTopLefts.at(index));
|
|
||||||
if (candidateOverlap < bestOverlap
|
|
||||||
|| (candidateOverlap == bestOverlap && candidateDistance < bestDistance)
|
|
||||||
|| (candidateOverlap == bestOverlap && candidateDistance == bestDistance && index < bestIndex))
|
|
||||||
{
|
|
||||||
bestTopLeft = candidateTopLeft;
|
|
||||||
bestOverlap = candidateOverlap;
|
|
||||||
bestDistance = candidateDistance;
|
|
||||||
bestIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestTopLeft;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateSummary()
|
|
||||||
{
|
|
||||||
const QString workspaceText = m_snapshot.workspaceRoot.trimmed().isEmpty()
|
|
||||||
? QStringLiteral("未选择工作区")
|
|
||||||
: m_snapshot.workspaceRoot;
|
|
||||||
const QString scanText = m_snapshot.lastScanAt.isValid()
|
|
||||||
? m_snapshot.lastScanAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))
|
|
||||||
: QStringLiteral("尚未扫描");
|
|
||||||
m_workspaceLabel->setText(QStringLiteral("本地工作区 Agent"));
|
|
||||||
m_scanLabel->setText(QStringLiteral("工作区:%1").arg(workspaceText));
|
|
||||||
m_summaryLabel->setText(QStringLiteral("索引项:%1 最近扫描:%2")
|
|
||||||
.arg(m_snapshot.indexedItemCount)
|
|
||||||
.arg(scanText));
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateResults()
|
|
||||||
{
|
|
||||||
const int currentRow = m_resultList->currentRow();
|
|
||||||
m_resultList->clear();
|
|
||||||
if (m_snapshot.lastResults.isEmpty())
|
|
||||||
{
|
|
||||||
auto *item = new QListWidgetItem(QStringLiteral("暂无上一轮结果"));
|
|
||||||
item->setFlags(Qt::NoItemFlags);
|
|
||||||
m_resultList->addItem(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int index = 0; index < m_snapshot.lastResults.size(); ++index)
|
|
||||||
{
|
|
||||||
m_resultList->addItem(QStringLiteral("%1. %2").arg(index + 1).arg(compactPath(m_snapshot.lastResults.at(index))));
|
|
||||||
}
|
|
||||||
if (currentRow >= 0 && currentRow < m_resultList->count())
|
|
||||||
{
|
|
||||||
m_resultList->setCurrentRow(currentRow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
m_resultList->setCurrentRow(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateSelectedDetails()
|
|
||||||
{
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
if (index < 0)
|
|
||||||
{
|
|
||||||
m_selectedDetailEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">请选择左侧结果。</div>"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index);
|
|
||||||
const QString html = QStringLiteral(
|
|
||||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
|
||||||
"<div style=\"color:#cbd5e1;\">类型:%2<br>大小:%3 字节<br>修改时间:%4</div>")
|
|
||||||
.arg(htmlLine(compactPath(file)))
|
|
||||||
.arg(fileTypeText(file))
|
|
||||||
.arg(file.sizeBytes)
|
|
||||||
.arg(file.lastModified.isValid() ? file.lastModified.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("未知"));
|
|
||||||
m_selectedDetailEdit->setHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateTextMatches()
|
|
||||||
{
|
|
||||||
if (m_snapshot.lastTextMatches.isEmpty())
|
|
||||||
{
|
|
||||||
m_textMatchesEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无文本搜索命中。</div>"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString html;
|
|
||||||
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInDetails);
|
|
||||||
for (int i = 0; i < matchCount; ++i)
|
|
||||||
{
|
|
||||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
|
|
||||||
html += QStringLiteral("<div style=\"margin-bottom:9px;\"><b style=\"color:#bfdbfe;\">%1:%2</b><br><span style=\"color:#dbe4f0;\">%3</span></div>")
|
|
||||||
.arg(htmlLine(match.relativePath))
|
|
||||||
.arg(match.lineNumber)
|
|
||||||
.arg(htmlLine(match.lineText));
|
|
||||||
}
|
|
||||||
if (m_snapshot.lastTextMatches.size() > matchCount)
|
|
||||||
{
|
|
||||||
html += QStringLiteral("<div style=\"color:#93a4bd;\">仅显示前 %1 条文本命中。</div>").arg(matchCount);
|
|
||||||
}
|
|
||||||
m_textMatchesEdit->setHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateRecentRead()
|
|
||||||
{
|
|
||||||
if (!m_snapshot.hasLastReadFile)
|
|
||||||
{
|
|
||||||
m_recentReadEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无最近读取文件。</div>"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString html = QStringLiteral(
|
|
||||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
|
||||||
"<pre style=\"white-space:pre-wrap; color:#dbe4f0; font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px; margin:0;\">%2</pre>")
|
|
||||||
.arg(htmlLine(m_snapshot.lastReadFile.relativePath))
|
|
||||||
.arg(m_snapshot.lastReadPreview.toHtmlEscaped());
|
|
||||||
m_recentReadEdit->setHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateDetails()
|
|
||||||
{
|
|
||||||
updateSelectedDetails();
|
|
||||||
updateTextMatches();
|
|
||||||
updateRecentRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspacePanel::updateActionButtons()
|
|
||||||
{
|
|
||||||
const int index = selectedResultIndex();
|
|
||||||
const bool hasSelection = index >= 0;
|
|
||||||
m_openButton->setEnabled(hasSelection);
|
|
||||||
m_revealButton->setEnabled(hasSelection);
|
|
||||||
m_readButton->setEnabled(hasSelection && !m_snapshot.lastResults.at(index).isDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
int WorkspacePanel::selectedResultIndex() const
|
|
||||||
{
|
|
||||||
const int row = m_resultList->currentRow();
|
|
||||||
if (row < 0 || row >= m_snapshot.lastResults.size())
|
|
||||||
{
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../workspace/WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QDialog>
|
|
||||||
#include <QMouseEvent>
|
|
||||||
#include <QPoint>
|
|
||||||
#include <QRect>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
class QLabel;
|
|
||||||
class QListWidget;
|
|
||||||
class QPushButton;
|
|
||||||
class QTextEdit;
|
|
||||||
|
|
||||||
class WorkspacePanel : public QDialog
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
using ResultActionCallback = std::function<void(int)>;
|
|
||||||
|
|
||||||
explicit WorkspacePanel(QWidget *parent = nullptr);
|
|
||||||
|
|
||||||
void setSnapshot(const WorkspacePanelSnapshot &snapshot);
|
|
||||||
void showNear(const QRect &avoidRect);
|
|
||||||
|
|
||||||
void setReadCallback(ResultActionCallback callback);
|
|
||||||
void setOpenCallback(ResultActionCallback callback);
|
|
||||||
void setRevealCallback(ResultActionCallback callback);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void mouseMoveEvent(QMouseEvent *event) override;
|
|
||||||
void mousePressEvent(QMouseEvent *event) override;
|
|
||||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QRect availableGeometryForRect(const QRect &rect) const;
|
|
||||||
QPoint constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const;
|
|
||||||
QPoint smartTopLeftNear(const QRect &avoidRect) const;
|
|
||||||
void updateSummary();
|
|
||||||
void updateResults();
|
|
||||||
void updateSelectedDetails();
|
|
||||||
void updateTextMatches();
|
|
||||||
void updateRecentRead();
|
|
||||||
void updateDetails();
|
|
||||||
void updateActionButtons();
|
|
||||||
int selectedResultIndex() const;
|
|
||||||
|
|
||||||
QLabel *m_workspaceLabel;
|
|
||||||
QLabel *m_scanLabel;
|
|
||||||
QLabel *m_summaryLabel;
|
|
||||||
QListWidget *m_resultList;
|
|
||||||
QTextEdit *m_selectedDetailEdit;
|
|
||||||
QTextEdit *m_textMatchesEdit;
|
|
||||||
QTextEdit *m_recentReadEdit;
|
|
||||||
QPushButton *m_readButton;
|
|
||||||
QPushButton *m_openButton;
|
|
||||||
QPushButton *m_revealButton;
|
|
||||||
QPushButton *m_closeButton;
|
|
||||||
WorkspacePanelSnapshot m_snapshot;
|
|
||||||
ResultActionCallback m_readCallback;
|
|
||||||
ResultActionCallback m_openCallback;
|
|
||||||
ResultActionCallback m_revealCallback;
|
|
||||||
QPoint m_dragStartPosition;
|
|
||||||
QPoint m_dragWindowPosition;
|
|
||||||
bool m_dragging = 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)
|
QString ResourcePaths::resourcePath(const QString &relativePath)
|
||||||
{
|
{
|
||||||
const QString cleanedRelativePath = cleanRelativePath(relativePath);
|
const QString cleanedRelativePath = cleanRelativePath(relativePath);
|
||||||
@@ -64,8 +53,3 @@ QString ResourcePaths::appIconPath()
|
|||||||
{
|
{
|
||||||
return resourcePath(QStringLiteral("icons/app_icon.ico"));
|
return resourcePath(QStringLiteral("icons/app_icon.ico"));
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ResourcePaths::appIconSourcePngPath()
|
|
||||||
{
|
|
||||||
return resourcePath(QStringLiteral("icons/app_icon_1024.png"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
class ResourcePaths
|
class ResourcePaths
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static QString resourcesRootPath();
|
|
||||||
static QString resourcePath(const QString &relativePath);
|
static QString resourcePath(const QString &relativePath);
|
||||||
static QString charactersRootPath();
|
static QString charactersRootPath();
|
||||||
static QString reminderSoundsRootPath();
|
static QString reminderSoundsRootPath();
|
||||||
static QString appIconPath();
|
static QString appIconPath();
|
||||||
static QString appIconSourcePngPath();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
#include "WorkspaceAccessPolicy.h"
|
|
||||||
|
|
||||||
#include <QDir>
|
|
||||||
#include <QFileInfo>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
QStringList systemPathPrefixes()
|
|
||||||
{
|
|
||||||
QStringList prefixes = {
|
|
||||||
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 = [&prefixes](const char *name) {
|
|
||||||
const QString value = qEnvironmentVariable(name).trimmed();
|
|
||||||
if (!value.isEmpty())
|
|
||||||
{
|
|
||||||
QString cleaned = QDir::cleanPath(QFileInfo(value).absoluteFilePath());
|
|
||||||
cleaned.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
|
||||||
prefixes.append(cleaned.toUpper());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
addEnvironmentPath("WINDIR");
|
|
||||||
addEnvironmentPath("SystemRoot");
|
|
||||||
addEnvironmentPath("ProgramFiles");
|
|
||||||
addEnvironmentPath("ProgramFiles(x86)");
|
|
||||||
addEnvironmentPath("ProgramData");
|
|
||||||
return prefixes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceAccessPolicy::WorkspaceAccessPolicy(const QString &workspaceRoot)
|
|
||||||
: m_workspaceRoot(cleanedAbsolutePath(workspaceRoot))
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::validateWorkspaceRoot(const QString &path, QString *errorMessage)
|
|
||||||
{
|
|
||||||
const QFileInfo info(path);
|
|
||||||
if (!info.exists() || !info.isDir())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("请选择一个存在的工作区目录。"));
|
|
||||||
}
|
|
||||||
if (!info.isReadable())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("工作区目录不可读取。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString absolutePath = info.absoluteFilePath();
|
|
||||||
if (pathHasSymlinkSegment(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("不允许选择符号链接或符号链接目录作为工作区。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceAccessPolicy policy(absolutePath);
|
|
||||||
if (policy.isSystemPath(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("不允许把 Windows 系统目录作为工作区。"));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAccessPolicy::workspaceRoot() const
|
|
||||||
{
|
|
||||||
return m_workspaceRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAccessPolicy::normalizePath(const QString &path) const
|
|
||||||
{
|
|
||||||
return cleanedAbsolutePath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAccessPolicy::toAbsolutePath(const QString &relativeOrAbsolutePath) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(relativeOrAbsolutePath);
|
|
||||||
if (info.isAbsolute())
|
|
||||||
{
|
|
||||||
return cleanedAbsolutePath(relativeOrAbsolutePath);
|
|
||||||
}
|
|
||||||
return cleanedAbsolutePath(QDir(m_workspaceRoot).filePath(relativeOrAbsolutePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAccessPolicy::toRelativePath(const QString &absolutePath) const
|
|
||||||
{
|
|
||||||
return QDir(m_workspaceRoot).relativeFilePath(cleanedAbsolutePath(absolutePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::isInsideWorkspace(const QString &path, QString *errorMessage) const
|
|
||||||
{
|
|
||||||
if (m_workspaceRoot.trimmed().isEmpty())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("尚未选择工作区。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString root = comparablePath(m_workspaceRoot);
|
|
||||||
const QString checkedPath = comparablePath(toAbsolutePath(path));
|
|
||||||
if (checkedPath == root || checkedPath.startsWith(root + QLatin1Char('/')))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return setError(errorMessage, QStringLiteral("路径不在当前工作区内。"));
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::hasSymlinkSegment(const QString &path, QString *errorMessage) const
|
|
||||||
{
|
|
||||||
if (pathHasSymlinkSegment(path))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("不允许访问符号链接或符号链接目录内的路径。"));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::isSystemPath(const QString &path) const
|
|
||||||
{
|
|
||||||
const QString checkedPath = cleanedAbsolutePath(path).toUpper();
|
|
||||||
for (const QString &prefix : systemPathPrefixes())
|
|
||||||
{
|
|
||||||
if (checkedPath == prefix || checkedPath.startsWith(prefix + QLatin1Char('/')))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::isDangerousSuffix(const QString &path) const
|
|
||||||
{
|
|
||||||
static const QStringList dangerous = {
|
|
||||||
QStringLiteral("exe"),
|
|
||||||
QStringLiteral("bat"),
|
|
||||||
QStringLiteral("cmd"),
|
|
||||||
QStringLiteral("ps1"),
|
|
||||||
QStringLiteral("vbs"),
|
|
||||||
QStringLiteral("msi"),
|
|
||||||
QStringLiteral("scr"),
|
|
||||||
QStringLiteral("reg"),
|
|
||||||
QStringLiteral("com"),
|
|
||||||
QStringLiteral("pif"),
|
|
||||||
QStringLiteral("lnk"),
|
|
||||||
};
|
|
||||||
return dangerous.contains(QFileInfo(path).suffix().trimmed().toLower());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::isSensitiveFile(const QString &path) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(path);
|
|
||||||
const QString fileName = info.fileName().toLower();
|
|
||||||
const QString suffix = info.suffix().toLower();
|
|
||||||
if (fileName == QStringLiteral(".env")
|
|
||||||
|| fileName == QStringLiteral("id_rsa")
|
|
||||||
|| fileName == QStringLiteral("id_ed25519")
|
|
||||||
|| fileName == QStringLiteral("credentials.json")
|
|
||||||
|| fileName == QStringLiteral("secrets.json")
|
|
||||||
|| fileName == QStringLiteral("token.json")
|
|
||||||
|| suffix == QStringLiteral("pem")
|
|
||||||
|| suffix == QStringLiteral("key")
|
|
||||||
|| suffix == QStringLiteral("pfx")
|
|
||||||
|| suffix == QStringLiteral("p12"))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const QStringList sensitiveKeywords = {
|
|
||||||
QStringLiteral("secret"),
|
|
||||||
QStringLiteral("token"),
|
|
||||||
QStringLiteral("password"),
|
|
||||||
QStringLiteral("credential"),
|
|
||||||
QStringLiteral("private"),
|
|
||||||
QStringLiteral("apikey"),
|
|
||||||
QStringLiteral("api_key"),
|
|
||||||
};
|
|
||||||
for (const QString &keyword : sensitiveKeywords)
|
|
||||||
{
|
|
||||||
if (fileName.contains(keyword))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::isSupportedTextFile(const QString &path) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(path);
|
|
||||||
const QString fileName = info.fileName().toLower();
|
|
||||||
const QString suffix = info.suffix().toLower();
|
|
||||||
if (fileName == QStringLiteral("cmakelists.txt"))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const QStringList supported = {
|
|
||||||
QStringLiteral("cpp"),
|
|
||||||
QStringLiteral("h"),
|
|
||||||
QStringLiteral("hpp"),
|
|
||||||
QStringLiteral("c"),
|
|
||||||
QStringLiteral("cc"),
|
|
||||||
QStringLiteral("py"),
|
|
||||||
QStringLiteral("js"),
|
|
||||||
QStringLiteral("ts"),
|
|
||||||
QStringLiteral("json"),
|
|
||||||
QStringLiteral("md"),
|
|
||||||
QStringLiteral("markdown"),
|
|
||||||
QStringLiteral("txt"),
|
|
||||||
QStringLiteral("log"),
|
|
||||||
QStringLiteral("ini"),
|
|
||||||
QStringLiteral("xml"),
|
|
||||||
QStringLiteral("yaml"),
|
|
||||||
QStringLiteral("yml"),
|
|
||||||
QStringLiteral("cmake"),
|
|
||||||
QStringLiteral("pro"),
|
|
||||||
QStringLiteral("css"),
|
|
||||||
QStringLiteral("html"),
|
|
||||||
QStringLiteral("qss"),
|
|
||||||
};
|
|
||||||
return supported.contains(suffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::canReadFile(const QString &path, QString *errorMessage) const
|
|
||||||
{
|
|
||||||
const QString absolutePath = toAbsolutePath(path);
|
|
||||||
if (!isInsideWorkspace(absolutePath, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfo info(absolutePath);
|
|
||||||
if (!info.exists() || !info.isFile())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("请选择工作区内存在的文件。"));
|
|
||||||
}
|
|
||||||
if (!info.isReadable())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("文件不可读取。"));
|
|
||||||
}
|
|
||||||
if (hasSymlinkSegment(absolutePath, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isSystemPath(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("不允许读取系统目录内的文件。"));
|
|
||||||
}
|
|
||||||
if (isSensitiveFile(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("该文件可能包含敏感信息,已拒绝读取。"));
|
|
||||||
}
|
|
||||||
if (!isSupportedTextFile(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("只允许读取工作区内常见文本文件。"));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::canOpenFile(const QString &path, QString *errorMessage) const
|
|
||||||
{
|
|
||||||
const QString absolutePath = toAbsolutePath(path);
|
|
||||||
if (!isInsideWorkspace(absolutePath, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfo info(absolutePath);
|
|
||||||
if (!info.exists() || !info.isFile())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("请选择工作区内存在的普通文件。"));
|
|
||||||
}
|
|
||||||
if (!info.isReadable())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("文件不可读取。"));
|
|
||||||
}
|
|
||||||
if (hasSymlinkSegment(absolutePath, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isSystemPath(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("不允许打开系统目录内的文件。"));
|
|
||||||
}
|
|
||||||
if (isSensitiveFile(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("该文件可能包含敏感信息,已拒绝打开。"));
|
|
||||||
}
|
|
||||||
if (isDangerousSuffix(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("该文件类型可能执行程序或脚本,已拒绝打开。"));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::canReadDirectory(const QString &path, QString *errorMessage) const
|
|
||||||
{
|
|
||||||
const QString absolutePath = toAbsolutePath(path);
|
|
||||||
if (!isInsideWorkspace(absolutePath, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfo info(absolutePath);
|
|
||||||
if (!info.exists() || !info.isDir())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("请选择工作区内存在的目录。"));
|
|
||||||
}
|
|
||||||
if (!info.isReadable())
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("目录不可读取。"));
|
|
||||||
}
|
|
||||||
if (hasSymlinkSegment(absolutePath, errorMessage))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isSystemPath(absolutePath))
|
|
||||||
{
|
|
||||||
return setError(errorMessage, QStringLiteral("不允许读取系统目录。"));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::setError(QString *errorMessage, const QString &message)
|
|
||||||
{
|
|
||||||
if (errorMessage != nullptr)
|
|
||||||
{
|
|
||||||
*errorMessage = message;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAccessPolicy::cleanedAbsolutePath(const QString &path)
|
|
||||||
{
|
|
||||||
QString cleaned = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
|
||||||
cleaned.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAccessPolicy::comparablePath(const QString &path)
|
|
||||||
{
|
|
||||||
return cleanedAbsolutePath(path).toUpper();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceAccessPolicy::pathHasSymlinkSegment(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;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
class WorkspaceAccessPolicy
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
WorkspaceAccessPolicy() = default;
|
|
||||||
explicit WorkspaceAccessPolicy(const QString &workspaceRoot);
|
|
||||||
|
|
||||||
static bool validateWorkspaceRoot(const QString &path, QString *errorMessage = nullptr);
|
|
||||||
|
|
||||||
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 canOpenFile(const QString &path, QString *errorMessage = nullptr) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
static bool setError(QString *errorMessage, const QString &message);
|
|
||||||
static QString cleanedAbsolutePath(const QString &path);
|
|
||||||
static QString comparablePath(const QString &path);
|
|
||||||
static bool pathHasSymlinkSegment(const QString &path);
|
|
||||||
|
|
||||||
QString m_workspaceRoot;
|
|
||||||
};
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#include "WorkspaceAgent.h"
|
|
||||||
|
|
||||||
QString WorkspaceAgent::systemPrompt()
|
|
||||||
{
|
|
||||||
return QStringLiteral(
|
|
||||||
"你是 QtDesktopPet 的本地工作区助手。\n"
|
|
||||||
"你只能基于用户授权的工作区文件进行分析。\n"
|
|
||||||
"你不能假设自己看过未读取的文件。\n"
|
|
||||||
"你不能要求访问工作区外路径。\n"
|
|
||||||
"你不能执行系统命令。\n"
|
|
||||||
"你不能生成危险操作指令。\n"
|
|
||||||
"涉及创建、修改、移动、重命名、删除文件时,必须说明当前阶段不支持写操作。\n"
|
|
||||||
"涉及敏感文件、密钥、凭证时,必须提醒风险。\n"
|
|
||||||
"回答代码问题时要引用具体文件路径。\n"
|
|
||||||
"回复保持简短、结构清晰,适合桌宠助手展示。");
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAgent::projectAnalysisPrompt(const QString &workspaceRoot, const QString &structureSummary)
|
|
||||||
{
|
|
||||||
return QStringLiteral(
|
|
||||||
"请基于以下本地工作区扫描摘要,简短分析这个项目的结构和主要模块。\n"
|
|
||||||
"不要声称读取了摘要之外的文件,不要提出执行命令。\n\n"
|
|
||||||
"工作区:%1\n\n"
|
|
||||||
"%2")
|
|
||||||
.arg(workspaceRoot, structureSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceAgent::fileSummaryPrompt(const QString &relativePath, const QString &content)
|
|
||||||
{
|
|
||||||
return QStringLiteral(
|
|
||||||
"请基于以下已读取的工作区文件内容,简短总结文件用途、关键结构和注意点。\n"
|
|
||||||
"不要声称读取了其他文件,不要提出执行命令。\n\n"
|
|
||||||
"文件:%1\n\n"
|
|
||||||
"内容:\n%2")
|
|
||||||
.arg(relativePath, content.left(24000));
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
class WorkspaceAgent
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
static QString systemPrompt();
|
|
||||||
static QString projectAnalysisPrompt(const QString &workspaceRoot, const QString &structureSummary);
|
|
||||||
static QString fileSummaryPrompt(const QString &relativePath, const QString &content);
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "WorkspaceScanner.h"
|
|
||||||
#include "WorkspaceSession.h"
|
|
||||||
#include "WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
class WorkspaceController
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
WorkspaceController();
|
|
||||||
|
|
||||||
bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr);
|
|
||||||
void closeWorkspace();
|
|
||||||
bool hasWorkspace() const;
|
|
||||||
QString workspaceRoot() const;
|
|
||||||
|
|
||||||
WorkspaceToolResult scanWorkspace();
|
|
||||||
WorkspaceToolResult handleMessage(const QString &message);
|
|
||||||
WorkspaceToolResult executeActionPlan(const WorkspaceActionPlan &plan);
|
|
||||||
WorkspaceToolResult readLastResult(int zeroBasedIndex);
|
|
||||||
WorkspaceToolResult openLastResult(int zeroBasedIndex);
|
|
||||||
WorkspaceToolResult revealLastResult(int zeroBasedIndex);
|
|
||||||
WorkspacePanelSnapshot snapshot() const;
|
|
||||||
QVector<WorkspaceFileInfo> lastResults() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
WorkspaceToolResult currentWorkspaceInfo() const;
|
|
||||||
WorkspaceToolResult listWorkspaceRoot() const;
|
|
||||||
WorkspaceToolResult searchFiles(const QString &keyword);
|
|
||||||
WorkspaceToolResult searchText(const QString &keyword);
|
|
||||||
WorkspaceToolResult readFileByQuery(const QString &query, bool requestSummary);
|
|
||||||
WorkspaceToolResult openItemByQuery(const QString &query, bool revealInFileManager);
|
|
||||||
WorkspaceToolResult itemFromLastResults(int zeroBasedIndex, WorkspaceFileInfo *file) const;
|
|
||||||
WorkspaceToolResult projectSummary(bool requestAiSummary) const;
|
|
||||||
|
|
||||||
QString extractSearchKeyword(const QString &message) const;
|
|
||||||
QString extractTextSearchKeyword(const QString &message) const;
|
|
||||||
QString extractReadTarget(const QString &message) const;
|
|
||||||
QString extractOpenTarget(const QString &message) const;
|
|
||||||
int ordinalIndex(const QString &message) const;
|
|
||||||
bool isTextSearchMessage(const QString &message) const;
|
|
||||||
bool isProjectSummaryMessage(const QString &message) const;
|
|
||||||
bool isFileSummaryMessage(const QString &message) const;
|
|
||||||
bool isOpenMessage(const QString &message) const;
|
|
||||||
bool isRevealMessage(const QString &message) const;
|
|
||||||
|
|
||||||
WorkspaceSession m_session;
|
|
||||||
WorkspaceScanner m_scanner;
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#include "WorkspaceIndex.h"
|
|
||||||
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
void WorkspaceIndex::clear()
|
|
||||||
{
|
|
||||||
m_files.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceIndex::setFiles(const QVector<WorkspaceFileInfo> &files)
|
|
||||||
{
|
|
||||||
m_files = files;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> WorkspaceIndex::allFiles() const
|
|
||||||
{
|
|
||||||
return m_files;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> WorkspaceIndex::findByName(const QString &keyword, int maxResults) const
|
|
||||||
{
|
|
||||||
const QString normalizedKeyword = keyword.trimmed().toLower();
|
|
||||||
if (normalizedKeyword.isEmpty() || maxResults <= 0)
|
|
||||||
{
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> matches;
|
|
||||||
for (const WorkspaceFileInfo &file : m_files)
|
|
||||||
{
|
|
||||||
const QString fileName = file.fileName.toLower();
|
|
||||||
const QString relativePath = file.relativePath.toLower();
|
|
||||||
const QString suffixToken = QStringLiteral(".") + file.suffix.toLower();
|
|
||||||
if (fileName.contains(normalizedKeyword)
|
|
||||||
|| relativePath.contains(normalizedKeyword)
|
|
||||||
|| (!file.suffix.isEmpty() && normalizedKeyword == suffixToken))
|
|
||||||
{
|
|
||||||
matches.append(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::sort(matches.begin(), matches.end(), [&normalizedKeyword](const WorkspaceFileInfo &left, const WorkspaceFileInfo &right) {
|
|
||||||
const bool leftExact = left.fileName.compare(normalizedKeyword, Qt::CaseInsensitive) == 0;
|
|
||||||
const bool rightExact = right.fileName.compare(normalizedKeyword, Qt::CaseInsensitive) == 0;
|
|
||||||
if (leftExact != rightExact)
|
|
||||||
{
|
|
||||||
return leftExact;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool leftFile = !left.isDirectory;
|
|
||||||
const bool rightFile = !right.isDirectory;
|
|
||||||
if (leftFile != rightFile)
|
|
||||||
{
|
|
||||||
return leftFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.lastModified > right.lastModified;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matches.size() > maxResults)
|
|
||||||
{
|
|
||||||
matches.resize(maxResults);
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> WorkspaceIndex::findBySuffix(const QStringList &suffixes, int maxResults) const
|
|
||||||
{
|
|
||||||
if (suffixes.isEmpty() || maxResults <= 0)
|
|
||||||
{
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
QStringList normalizedSuffixes;
|
|
||||||
for (QString suffix : suffixes)
|
|
||||||
{
|
|
||||||
suffix = suffix.trimmed().toLower();
|
|
||||||
suffix.remove(QLatin1Char('.'));
|
|
||||||
if (!suffix.isEmpty())
|
|
||||||
{
|
|
||||||
normalizedSuffixes.append(suffix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> matches;
|
|
||||||
for (const WorkspaceFileInfo &file : m_files)
|
|
||||||
{
|
|
||||||
if (!file.isDirectory && normalizedSuffixes.contains(file.suffix.toLower()))
|
|
||||||
{
|
|
||||||
matches.append(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::sort(matches.begin(), matches.end(), [](const WorkspaceFileInfo &left, const WorkspaceFileInfo &right) {
|
|
||||||
return left.lastModified > right.lastModified;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matches.size() > maxResults)
|
|
||||||
{
|
|
||||||
matches.resize(maxResults);
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> WorkspaceIndex::recentFiles(int maxResults) const
|
|
||||||
{
|
|
||||||
if (maxResults <= 0)
|
|
||||||
{
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> files;
|
|
||||||
for (const WorkspaceFileInfo &file : m_files)
|
|
||||||
{
|
|
||||||
if (!file.isDirectory)
|
|
||||||
{
|
|
||||||
files.append(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::sort(files.begin(), files.end(), [](const WorkspaceFileInfo &left, const WorkspaceFileInfo &right) {
|
|
||||||
return left.lastModified > right.lastModified;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (files.size() > maxResults)
|
|
||||||
{
|
|
||||||
files.resize(maxResults);
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
int WorkspaceIndex::size() const
|
|
||||||
{
|
|
||||||
return m_files.size();
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
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;
|
|
||||||
int size() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QVector<WorkspaceFileInfo> m_files;
|
|
||||||
};
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
#include "WorkspaceScanner.h"
|
|
||||||
|
|
||||||
#include <QDir>
|
|
||||||
#include <QFileInfo>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
struct PendingDirectory
|
|
||||||
{
|
|
||||||
QString path;
|
|
||||||
int depth = 0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceScanner::WorkspaceScanner(int maxDepth, int maxEntries)
|
|
||||||
: m_maxDepth(maxDepth)
|
|
||||||
, m_maxEntries(maxEntries)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> WorkspaceScanner::scan(const QString &workspaceRoot, QString *errorMessage) const
|
|
||||||
{
|
|
||||||
if (!WorkspaceAccessPolicy::validateWorkspaceRoot(workspaceRoot, errorMessage))
|
|
||||||
{
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceAccessPolicy policy(workspaceRoot);
|
|
||||||
QVector<WorkspaceFileInfo> files;
|
|
||||||
QVector<PendingDirectory> pending;
|
|
||||||
pending.append({policy.workspaceRoot(), 0});
|
|
||||||
|
|
||||||
while (!pending.isEmpty() && files.size() < m_maxEntries)
|
|
||||||
{
|
|
||||||
const PendingDirectory current = pending.takeLast();
|
|
||||||
QString directoryError;
|
|
||||||
if (!policy.canReadDirectory(current.path, &directoryError))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfoList entries = QDir(current.path).entryInfoList(
|
|
||||||
QDir::NoDotAndDotDot | QDir::AllEntries | QDir::Hidden,
|
|
||||||
QDir::DirsFirst | QDir::Name);
|
|
||||||
|
|
||||||
for (const QFileInfo &entry : entries)
|
|
||||||
{
|
|
||||||
if (files.size() >= m_maxEntries)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (entry.isHidden())
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString absolutePath = entry.absoluteFilePath();
|
|
||||||
if (entry.isDir())
|
|
||||||
{
|
|
||||||
if (shouldSkipDirectory(policy, absolutePath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
files.append(fileInfoForPath(policy, absolutePath));
|
|
||||||
if (current.depth + 1 <= m_maxDepth)
|
|
||||||
{
|
|
||||||
pending.append({absolutePath, current.depth + 1});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isFile())
|
|
||||||
{
|
|
||||||
if (policy.hasSymlinkSegment(absolutePath))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
files.append(fileInfoForPath(policy, absolutePath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceScanner::shouldSkipDirectory(const WorkspaceAccessPolicy &policy, const QString &path) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(path);
|
|
||||||
const QString name = info.fileName().toLower();
|
|
||||||
static const QStringList skipped = {
|
|
||||||
QStringLiteral(".git"),
|
|
||||||
QStringLiteral(".vs"),
|
|
||||||
QStringLiteral(".idea"),
|
|
||||||
QStringLiteral("build"),
|
|
||||||
QStringLiteral("cmake-build-debug"),
|
|
||||||
QStringLiteral("cmake-build-release"),
|
|
||||||
QStringLiteral("node_modules"),
|
|
||||||
QStringLiteral("dist"),
|
|
||||||
QStringLiteral("release"),
|
|
||||||
QStringLiteral("release_packages"),
|
|
||||||
QStringLiteral("logs"),
|
|
||||||
};
|
|
||||||
return skipped.contains(name)
|
|
||||||
|| policy.isSystemPath(path)
|
|
||||||
|| policy.hasSymlinkSegment(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceFileInfo WorkspaceScanner::fileInfoForPath(const WorkspaceAccessPolicy &policy, const QString &path) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(path);
|
|
||||||
WorkspaceFileInfo file;
|
|
||||||
file.absolutePath = policy.normalizePath(path);
|
|
||||||
file.relativePath = policy.toRelativePath(file.absolutePath);
|
|
||||||
file.fileName = info.fileName();
|
|
||||||
file.suffix = info.suffix();
|
|
||||||
file.sizeBytes = info.size();
|
|
||||||
file.lastModified = info.lastModified();
|
|
||||||
file.isDirectory = info.isDir();
|
|
||||||
file.isTextFile = !file.isDirectory && policy.isSupportedTextFile(file.absolutePath) && !policy.isSensitiveFile(file.absolutePath);
|
|
||||||
file.isHidden = info.isHidden();
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "WorkspaceAccessPolicy.h"
|
|
||||||
#include "WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
class WorkspaceScanner
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit WorkspaceScanner(int maxDepth = 8, int maxEntries = 5000);
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> scan(const QString &workspaceRoot, QString *errorMessage = nullptr) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool shouldSkipDirectory(const WorkspaceAccessPolicy &policy, const QString &path) const;
|
|
||||||
WorkspaceFileInfo fileInfoForPath(const WorkspaceAccessPolicy &policy, const QString &path) const;
|
|
||||||
|
|
||||||
int m_maxDepth = 8;
|
|
||||||
int m_maxEntries = 5000;
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#include "WorkspaceSession.h"
|
|
||||||
|
|
||||||
bool WorkspaceSession::hasWorkspace() const
|
|
||||||
{
|
|
||||||
return !m_workspaceRoot.trimmed().isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceSession::workspaceRoot() const
|
|
||||||
{
|
|
||||||
return m_workspaceRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceSession::setWorkspaceRoot(const QString &workspaceRoot)
|
|
||||||
{
|
|
||||||
m_workspaceRoot = workspaceRoot;
|
|
||||||
m_index.clear();
|
|
||||||
m_lastScanAt = {};
|
|
||||||
m_lastResults.clear();
|
|
||||||
m_lastTextMatches.clear();
|
|
||||||
m_lastReadFile = {};
|
|
||||||
m_lastReadContent.clear();
|
|
||||||
m_hasLastReadFile = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceSession::clear()
|
|
||||||
{
|
|
||||||
setWorkspaceRoot(QString());
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceIndex &WorkspaceSession::index()
|
|
||||||
{
|
|
||||||
return m_index;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceIndex &WorkspaceSession::index() const
|
|
||||||
{
|
|
||||||
return m_index;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceSession::setLastScanAt(const QDateTime &dateTime)
|
|
||||||
{
|
|
||||||
m_lastScanAt = dateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
QDateTime WorkspaceSession::lastScanAt() const
|
|
||||||
{
|
|
||||||
return m_lastScanAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceSession::setLastResults(const QVector<WorkspaceFileInfo> &files)
|
|
||||||
{
|
|
||||||
m_lastResults = files;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceFileInfo> WorkspaceSession::lastResults() const
|
|
||||||
{
|
|
||||||
return m_lastResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceSession::setLastTextMatches(const QVector<WorkspaceTextMatch> &matches)
|
|
||||||
{
|
|
||||||
m_lastTextMatches = matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVector<WorkspaceTextMatch> WorkspaceSession::lastTextMatches() const
|
|
||||||
{
|
|
||||||
return m_lastTextMatches;
|
|
||||||
}
|
|
||||||
|
|
||||||
void WorkspaceSession::setLastReadFile(const WorkspaceFileInfo &file, const QString &content)
|
|
||||||
{
|
|
||||||
m_lastReadFile = file;
|
|
||||||
m_lastReadContent = content;
|
|
||||||
m_hasLastReadFile = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceFileInfo WorkspaceSession::lastReadFile() const
|
|
||||||
{
|
|
||||||
return m_lastReadFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString WorkspaceSession::lastReadContent() const
|
|
||||||
{
|
|
||||||
return m_lastReadContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool WorkspaceSession::hasLastReadFile() const
|
|
||||||
{
|
|
||||||
return m_hasLastReadFile;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "WorkspaceIndex.h"
|
|
||||||
#include "WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QString>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
class WorkspaceSession
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
bool hasWorkspace() const;
|
|
||||||
QString workspaceRoot() const;
|
|
||||||
void setWorkspaceRoot(const QString &workspaceRoot);
|
|
||||||
void clear();
|
|
||||||
|
|
||||||
WorkspaceIndex &index();
|
|
||||||
const WorkspaceIndex &index() const;
|
|
||||||
|
|
||||||
void setLastScanAt(const QDateTime &dateTime);
|
|
||||||
QDateTime lastScanAt() const;
|
|
||||||
|
|
||||||
void setLastResults(const QVector<WorkspaceFileInfo> &files);
|
|
||||||
QVector<WorkspaceFileInfo> lastResults() const;
|
|
||||||
|
|
||||||
void setLastTextMatches(const QVector<WorkspaceTextMatch> &matches);
|
|
||||||
QVector<WorkspaceTextMatch> lastTextMatches() const;
|
|
||||||
|
|
||||||
void setLastReadFile(const WorkspaceFileInfo &file, const QString &content);
|
|
||||||
WorkspaceFileInfo lastReadFile() const;
|
|
||||||
QString lastReadContent() const;
|
|
||||||
bool hasLastReadFile() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QString m_workspaceRoot;
|
|
||||||
WorkspaceIndex m_index;
|
|
||||||
QDateTime m_lastScanAt;
|
|
||||||
QVector<WorkspaceFileInfo> m_lastResults;
|
|
||||||
QVector<WorkspaceTextMatch> m_lastTextMatches;
|
|
||||||
WorkspaceFileInfo m_lastReadFile;
|
|
||||||
QString m_lastReadContent;
|
|
||||||
bool m_hasLastReadFile = false;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "WorkspaceTypes.h"
|
|
||||||
|
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
class WorkspaceTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
virtual ~WorkspaceTool() = default;
|
|
||||||
|
|
||||||
virtual QString name() const = 0;
|
|
||||||
virtual QString displayName() const = 0;
|
|
||||||
virtual WorkspaceToolRisk risk() const = 0;
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QString>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QVector>
|
|
||||||
|
|
||||||
enum class WorkspaceActionType
|
|
||||||
{
|
|
||||||
Unknown,
|
|
||||||
ListFiles,
|
|
||||||
SearchFiles,
|
|
||||||
SearchText,
|
|
||||||
ReadFile,
|
|
||||||
ProjectSummary,
|
|
||||||
FileSummary,
|
|
||||||
OpenFile,
|
|
||||||
OpenDirectory,
|
|
||||||
RevealInFileManager,
|
|
||||||
};
|
|
||||||
|
|
||||||
enum class WorkspaceToolRisk
|
|
||||||
{
|
|
||||||
ReadOnly,
|
|
||||||
Low,
|
|
||||||
Medium,
|
|
||||||
High,
|
|
||||||
Dangerous,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct WorkspaceFileInfo
|
|
||||||
{
|
|
||||||
QString relativePath;
|
|
||||||
QString absolutePath;
|
|
||||||
QString fileName;
|
|
||||||
QString suffix;
|
|
||||||
qint64 sizeBytes = 0;
|
|
||||||
QDateTime lastModified;
|
|
||||||
bool isDirectory = false;
|
|
||||||
bool isTextFile = false;
|
|
||||||
bool isHidden = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct WorkspaceTextMatch
|
|
||||||
{
|
|
||||||
QString relativePath;
|
|
||||||
QString absolutePath;
|
|
||||||
int lineNumber = 0;
|
|
||||||
QString lineText;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct WorkspaceToolResult
|
|
||||||
{
|
|
||||||
bool success = false;
|
|
||||||
QString message;
|
|
||||||
QString errorMessage;
|
|
||||||
QString outputText;
|
|
||||||
QString targetPath;
|
|
||||||
QVector<WorkspaceFileInfo> files;
|
|
||||||
QVector<WorkspaceTextMatch> textMatches;
|
|
||||||
bool shouldRequestAiSummary = false;
|
|
||||||
QString aiPrompt;
|
|
||||||
QString aiFallbackText;
|
|
||||||
bool hasActionPlan = false;
|
|
||||||
WorkspaceActionPlan actionPlan;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct WorkspacePanelSnapshot
|
|
||||||
{
|
|
||||||
QString workspaceRoot;
|
|
||||||
QDateTime lastScanAt;
|
|
||||||
int indexedItemCount = 0;
|
|
||||||
QVector<WorkspaceFileInfo> lastResults;
|
|
||||||
QVector<WorkspaceTextMatch> lastTextMatches;
|
|
||||||
bool hasLastReadFile = false;
|
|
||||||
WorkspaceFileInfo lastReadFile;
|
|
||||||
QString lastReadPreview;
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#include "ListFilesTool.h"
|
|
||||||
|
|
||||||
#include <QDir>
|
|
||||||
#include <QFileInfo>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr int MaxListEntries = 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListFilesTool::ListFilesTool(const WorkspaceAccessPolicy &policy)
|
|
||||||
: m_policy(policy)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult ListFilesTool::run(const QString &directoryPath) const
|
|
||||||
{
|
|
||||||
const QString targetDirectory = directoryPath.trimmed().isEmpty()
|
|
||||||
? m_policy.workspaceRoot()
|
|
||||||
: m_policy.toAbsolutePath(directoryPath);
|
|
||||||
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
QString errorMessage;
|
|
||||||
if (!m_policy.canReadDirectory(targetDirectory, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfoList entries = QDir(targetDirectory).entryInfoList(
|
|
||||||
QDir::NoDotAndDotDot | QDir::AllEntries,
|
|
||||||
QDir::DirsFirst | QDir::Name);
|
|
||||||
|
|
||||||
QStringList lines;
|
|
||||||
lines.append(QStringLiteral("目录:%1").arg(m_policy.toRelativePath(targetDirectory)));
|
|
||||||
const int count = qMin(entries.size(), MaxListEntries);
|
|
||||||
for (int index = 0; index < count; ++index)
|
|
||||||
{
|
|
||||||
const QFileInfo &entry = entries.at(index);
|
|
||||||
const QString label = entry.isDir() ? QStringLiteral("[目录]") : QStringLiteral("[文件]");
|
|
||||||
lines.append(QStringLiteral("%1 %2").arg(label, entry.fileName()));
|
|
||||||
}
|
|
||||||
if (entries.size() > MaxListEntries)
|
|
||||||
{
|
|
||||||
lines.append(QStringLiteral("[仅显示前 %1 项,共 %2 项]").arg(MaxListEntries).arg(entries.size()));
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success = true;
|
|
||||||
result.message = QStringLiteral("已列出工作区目录。");
|
|
||||||
result.outputText = lines.join(QLatin1Char('\n'));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../WorkspaceAccessPolicy.h"
|
|
||||||
#include "../WorkspaceTypes.h"
|
|
||||||
|
|
||||||
class ListFilesTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit ListFilesTool(const WorkspaceAccessPolicy &policy);
|
|
||||||
|
|
||||||
WorkspaceToolResult run(const QString &directoryPath = QString()) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
const WorkspaceAccessPolicy &m_policy;
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#include "OpenDirectoryTool.h"
|
|
||||||
|
|
||||||
#include <QDesktopServices>
|
|
||||||
#include <QFileInfo>
|
|
||||||
#include <QUrl>
|
|
||||||
|
|
||||||
OpenDirectoryTool::OpenDirectoryTool(const WorkspaceAccessPolicy &policy)
|
|
||||||
: m_policy(policy)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult OpenDirectoryTool::run(const WorkspaceFileInfo &directory) const
|
|
||||||
{
|
|
||||||
return run(directory.absolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult OpenDirectoryTool::run(const QString &directoryPath) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
const QString absolutePath = m_policy.toAbsolutePath(directoryPath);
|
|
||||||
QString errorMessage;
|
|
||||||
if (!m_policy.canReadDirectory(absolutePath, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceFileInfo directory = directoryInfoForPath(absolutePath);
|
|
||||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory.absolutePath)))
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("打开文件夹失败。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success = true;
|
|
||||||
result.message = QStringLiteral("已打开文件夹:%1").arg(directory.relativePath);
|
|
||||||
result.targetPath = directory.absolutePath;
|
|
||||||
result.files.append(directory);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceFileInfo OpenDirectoryTool::directoryInfoForPath(const QString &directoryPath) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(directoryPath);
|
|
||||||
WorkspaceFileInfo directory;
|
|
||||||
directory.absolutePath = m_policy.normalizePath(directoryPath);
|
|
||||||
directory.relativePath = m_policy.toRelativePath(directory.absolutePath);
|
|
||||||
directory.fileName = info.fileName();
|
|
||||||
directory.suffix = info.suffix();
|
|
||||||
directory.sizeBytes = info.size();
|
|
||||||
directory.lastModified = info.lastModified();
|
|
||||||
directory.isDirectory = info.isDir();
|
|
||||||
directory.isTextFile = false;
|
|
||||||
directory.isHidden = info.isHidden();
|
|
||||||
return directory;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../WorkspaceAccessPolicy.h"
|
|
||||||
#include "../WorkspaceTypes.h"
|
|
||||||
|
|
||||||
class OpenDirectoryTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit OpenDirectoryTool(const WorkspaceAccessPolicy &policy);
|
|
||||||
|
|
||||||
WorkspaceToolResult run(const WorkspaceFileInfo &directory) const;
|
|
||||||
WorkspaceToolResult run(const QString &directoryPath) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
WorkspaceFileInfo directoryInfoForPath(const QString &directoryPath) const;
|
|
||||||
|
|
||||||
const WorkspaceAccessPolicy &m_policy;
|
|
||||||
};
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#include "OpenFileTool.h"
|
|
||||||
|
|
||||||
#include <QDesktopServices>
|
|
||||||
#include <QFileInfo>
|
|
||||||
#include <QUrl>
|
|
||||||
|
|
||||||
OpenFileTool::OpenFileTool(const WorkspaceAccessPolicy &policy)
|
|
||||||
: m_policy(policy)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult OpenFileTool::prepare(const WorkspaceFileInfo &file) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
QString errorMessage;
|
|
||||||
if (!m_policy.canOpenFile(file.absolutePath, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceFileInfo normalizedFile = fileInfoForPath(file.absolutePath);
|
|
||||||
WorkspaceActionPlan plan;
|
|
||||||
plan.type = WorkspaceActionType::OpenFile;
|
|
||||||
plan.risk = WorkspaceToolRisk::Low;
|
|
||||||
plan.title = QStringLiteral("打开工作区文件");
|
|
||||||
plan.description = QStringLiteral("将用系统默认程序打开工作区文件:\n%1").arg(normalizedFile.relativePath);
|
|
||||||
plan.sourcePath = normalizedFile.absolutePath;
|
|
||||||
plan.relativeSourcePath = normalizedFile.relativePath;
|
|
||||||
plan.requiresConfirmation = true;
|
|
||||||
|
|
||||||
result.success = true;
|
|
||||||
result.message = QStringLiteral("准备打开:%1").arg(normalizedFile.relativePath);
|
|
||||||
result.targetPath = normalizedFile.absolutePath;
|
|
||||||
result.files.append(normalizedFile);
|
|
||||||
result.hasActionPlan = true;
|
|
||||||
result.actionPlan = plan;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult OpenFileTool::execute(const WorkspaceActionPlan &plan) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
if (plan.type != WorkspaceActionType::OpenFile)
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("工作区动作类型不匹配。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString errorMessage;
|
|
||||||
if (!m_policy.canOpenFile(plan.sourcePath, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceFileInfo file = fileInfoForPath(plan.sourcePath);
|
|
||||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(file.absolutePath)))
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("打开文件失败。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success = true;
|
|
||||||
result.message = QStringLiteral("已打开:%1").arg(file.relativePath);
|
|
||||||
result.targetPath = file.absolutePath;
|
|
||||||
result.files.append(file);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceFileInfo OpenFileTool::fileInfoForPath(const QString &filePath) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(filePath);
|
|
||||||
WorkspaceFileInfo file;
|
|
||||||
file.absolutePath = m_policy.normalizePath(filePath);
|
|
||||||
file.relativePath = m_policy.toRelativePath(file.absolutePath);
|
|
||||||
file.fileName = info.fileName();
|
|
||||||
file.suffix = info.suffix();
|
|
||||||
file.sizeBytes = info.size();
|
|
||||||
file.lastModified = info.lastModified();
|
|
||||||
file.isDirectory = info.isDir();
|
|
||||||
file.isTextFile = !file.isDirectory && m_policy.isSupportedTextFile(file.absolutePath) && !m_policy.isSensitiveFile(file.absolutePath);
|
|
||||||
file.isHidden = info.isHidden();
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../WorkspaceAccessPolicy.h"
|
|
||||||
#include "../WorkspaceTypes.h"
|
|
||||||
|
|
||||||
class OpenFileTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit OpenFileTool(const WorkspaceAccessPolicy &policy);
|
|
||||||
|
|
||||||
WorkspaceToolResult prepare(const WorkspaceFileInfo &file) const;
|
|
||||||
WorkspaceToolResult execute(const WorkspaceActionPlan &plan) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
WorkspaceFileInfo fileInfoForPath(const QString &filePath) const;
|
|
||||||
|
|
||||||
const WorkspaceAccessPolicy &m_policy;
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#include "ReadFileTool.h"
|
|
||||||
|
|
||||||
#include <QFile>
|
|
||||||
#include <QFileInfo>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr qint64 MaxReadBytes = 128 * 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReadFileTool::ReadFileTool(const WorkspaceAccessPolicy &policy)
|
|
||||||
: m_policy(policy)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult ReadFileTool::run(const WorkspaceFileInfo &file) const
|
|
||||||
{
|
|
||||||
return run(file.absolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult ReadFileTool::run(const QString &filePath) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
const QString absolutePath = m_policy.toAbsolutePath(filePath);
|
|
||||||
QString errorMessage;
|
|
||||||
if (!m_policy.canReadFile(absolutePath, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
QFile file(absolutePath);
|
|
||||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("无法读取工作区文件。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QByteArray content = file.read(MaxReadBytes + 1);
|
|
||||||
QString text = QString::fromUtf8(content.left(MaxReadBytes));
|
|
||||||
if (content.size() > MaxReadBytes)
|
|
||||||
{
|
|
||||||
text += QStringLiteral("\n\n[文件较大,仅显示前 128KB。]");
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceFileInfo info = fileInfoForPath(absolutePath);
|
|
||||||
result.success = true;
|
|
||||||
result.message = QStringLiteral("已读取:%1").arg(info.relativePath);
|
|
||||||
result.outputText = text;
|
|
||||||
result.targetPath = absolutePath;
|
|
||||||
result.files.append(info);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceFileInfo ReadFileTool::fileInfoForPath(const QString &filePath) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(filePath);
|
|
||||||
WorkspaceFileInfo file;
|
|
||||||
file.absolutePath = m_policy.normalizePath(filePath);
|
|
||||||
file.relativePath = m_policy.toRelativePath(file.absolutePath);
|
|
||||||
file.fileName = info.fileName();
|
|
||||||
file.suffix = info.suffix();
|
|
||||||
file.sizeBytes = info.size();
|
|
||||||
file.lastModified = info.lastModified();
|
|
||||||
file.isDirectory = info.isDir();
|
|
||||||
file.isTextFile = !file.isDirectory && m_policy.isSupportedTextFile(file.absolutePath) && !m_policy.isSensitiveFile(file.absolutePath);
|
|
||||||
file.isHidden = info.isHidden();
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../WorkspaceAccessPolicy.h"
|
|
||||||
#include "../WorkspaceTypes.h"
|
|
||||||
|
|
||||||
class ReadFileTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit ReadFileTool(const WorkspaceAccessPolicy &policy);
|
|
||||||
|
|
||||||
WorkspaceToolResult run(const WorkspaceFileInfo &file) const;
|
|
||||||
WorkspaceToolResult run(const QString &filePath) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
WorkspaceFileInfo fileInfoForPath(const QString &filePath) const;
|
|
||||||
|
|
||||||
const WorkspaceAccessPolicy &m_policy;
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#include "RevealInFileManagerTool.h"
|
|
||||||
|
|
||||||
#include <QDesktopServices>
|
|
||||||
#include <QDir>
|
|
||||||
#include <QFileInfo>
|
|
||||||
#include <QUrl>
|
|
||||||
|
|
||||||
RevealInFileManagerTool::RevealInFileManagerTool(const WorkspaceAccessPolicy &policy)
|
|
||||||
: m_policy(policy)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult RevealInFileManagerTool::run(const WorkspaceFileInfo &item) const
|
|
||||||
{
|
|
||||||
return run(item.absolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult RevealInFileManagerTool::run(const QString &path) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
const QString absolutePath = m_policy.toAbsolutePath(path);
|
|
||||||
const QFileInfo info(absolutePath);
|
|
||||||
QString errorMessage;
|
|
||||||
if (info.isDir())
|
|
||||||
{
|
|
||||||
if (!m_policy.canReadDirectory(absolutePath, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!m_policy.canOpenFile(absolutePath, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceFileInfo item = itemInfoForPath(absolutePath);
|
|
||||||
const QString directoryToOpen = item.isDirectory ? item.absolutePath : QFileInfo(item.absolutePath).absolutePath();
|
|
||||||
if (!m_policy.canReadDirectory(directoryToOpen, &errorMessage))
|
|
||||||
{
|
|
||||||
result.errorMessage = errorMessage;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(QDir::cleanPath(directoryToOpen))))
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("打开所在文件夹失败。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success = true;
|
|
||||||
result.message = item.isDirectory
|
|
||||||
? QStringLiteral("已打开文件夹:%1").arg(item.relativePath)
|
|
||||||
: QStringLiteral("已打开所在文件夹:%1").arg(item.relativePath);
|
|
||||||
result.targetPath = item.absolutePath;
|
|
||||||
result.files.append(item);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceFileInfo RevealInFileManagerTool::itemInfoForPath(const QString &path) const
|
|
||||||
{
|
|
||||||
const QFileInfo info(path);
|
|
||||||
WorkspaceFileInfo item;
|
|
||||||
item.absolutePath = m_policy.normalizePath(path);
|
|
||||||
item.relativePath = m_policy.toRelativePath(item.absolutePath);
|
|
||||||
item.fileName = info.fileName();
|
|
||||||
item.suffix = info.suffix();
|
|
||||||
item.sizeBytes = info.size();
|
|
||||||
item.lastModified = info.lastModified();
|
|
||||||
item.isDirectory = info.isDir();
|
|
||||||
item.isTextFile = !item.isDirectory && m_policy.isSupportedTextFile(item.absolutePath) && !m_policy.isSensitiveFile(item.absolutePath);
|
|
||||||
item.isHidden = info.isHidden();
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../WorkspaceAccessPolicy.h"
|
|
||||||
#include "../WorkspaceTypes.h"
|
|
||||||
|
|
||||||
class RevealInFileManagerTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit RevealInFileManagerTool(const WorkspaceAccessPolicy &policy);
|
|
||||||
|
|
||||||
WorkspaceToolResult run(const WorkspaceFileInfo &item) const;
|
|
||||||
WorkspaceToolResult run(const QString &path) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
WorkspaceFileInfo itemInfoForPath(const QString &path) const;
|
|
||||||
|
|
||||||
const WorkspaceAccessPolicy &m_policy;
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#include "SearchFilesTool.h"
|
|
||||||
|
|
||||||
SearchFilesTool::SearchFilesTool(const WorkspaceIndex &index)
|
|
||||||
: m_index(index)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult SearchFilesTool::run(const QString &keyword, int maxResults) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
const QString normalizedKeyword = keyword.trimmed();
|
|
||||||
if (normalizedKeyword.isEmpty())
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("请输入要搜索的文件名关键词。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.files = m_index.findByName(normalizedKeyword, maxResults);
|
|
||||||
result.success = true;
|
|
||||||
if (result.files.isEmpty())
|
|
||||||
{
|
|
||||||
result.message = QStringLiteral("没有找到匹配的文件。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
QStringList lines;
|
|
||||||
lines.append(QStringLiteral("文件搜索:%1").arg(normalizedKeyword));
|
|
||||||
for (int index = 0; index < result.files.size(); ++index)
|
|
||||||
{
|
|
||||||
const WorkspaceFileInfo &file = result.files.at(index);
|
|
||||||
lines.append(QStringLiteral("%1. %2%3")
|
|
||||||
.arg(index + 1)
|
|
||||||
.arg(file.relativePath)
|
|
||||||
.arg(file.isDirectory ? QStringLiteral(" [目录]") : QString()));
|
|
||||||
}
|
|
||||||
result.message = QStringLiteral("找到 %1 个匹配项。").arg(result.files.size());
|
|
||||||
result.outputText = lines.join(QLatin1Char('\n'));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../WorkspaceIndex.h"
|
|
||||||
#include "../WorkspaceTypes.h"
|
|
||||||
|
|
||||||
class SearchFilesTool
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit SearchFilesTool(const WorkspaceIndex &index);
|
|
||||||
|
|
||||||
WorkspaceToolResult run(const QString &keyword, int maxResults = 30) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
const WorkspaceIndex &m_index;
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
#include "SearchTextTool.h"
|
|
||||||
|
|
||||||
#include <QFile>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QtGlobal>
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr qint64 MaxSearchReadBytes = 128 * 1024;
|
|
||||||
constexpr int MaxSnippetLength = 180;
|
|
||||||
|
|
||||||
QString trimmedSnippet(const QString &line)
|
|
||||||
{
|
|
||||||
QString snippet = line.simplified();
|
|
||||||
if (snippet.size() > MaxSnippetLength)
|
|
||||||
{
|
|
||||||
snippet = snippet.left(MaxSnippetLength) + QStringLiteral("...");
|
|
||||||
}
|
|
||||||
return snippet;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SearchTextTool::SearchTextTool(const WorkspaceAccessPolicy &policy, const WorkspaceIndex &index)
|
|
||||||
: m_policy(policy)
|
|
||||||
, m_index(index)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkspaceToolResult SearchTextTool::run(const QString &keyword, int maxFilesToScan, int maxMatches) const
|
|
||||||
{
|
|
||||||
WorkspaceToolResult result;
|
|
||||||
const QString normalizedKeyword = keyword.trimmed();
|
|
||||||
if (normalizedKeyword.isEmpty())
|
|
||||||
{
|
|
||||||
result.errorMessage = QStringLiteral("请输入要搜索的文本关键词。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
int scannedFiles = 0;
|
|
||||||
for (const WorkspaceFileInfo &fileInfo : m_index.allFiles())
|
|
||||||
{
|
|
||||||
if (scannedFiles >= maxFilesToScan || result.textMatches.size() >= maxMatches)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (fileInfo.isDirectory || !fileInfo.isTextFile)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString errorMessage;
|
|
||||||
if (!m_policy.canReadFile(fileInfo.absolutePath, &errorMessage))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
QFile file(fileInfo.absolutePath);
|
|
||||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString text = QString::fromUtf8(file.read(MaxSearchReadBytes));
|
|
||||||
const QStringList lines = text.split(QLatin1Char('\n'));
|
|
||||||
for (int lineIndex = 0; lineIndex < lines.size() && result.textMatches.size() < maxMatches; ++lineIndex)
|
|
||||||
{
|
|
||||||
const QString line = lines.at(lineIndex);
|
|
||||||
if (line.contains(normalizedKeyword, Qt::CaseInsensitive))
|
|
||||||
{
|
|
||||||
result.textMatches.append(WorkspaceTextMatch{
|
|
||||||
fileInfo.relativePath,
|
|
||||||
fileInfo.absolutePath,
|
|
||||||
lineIndex + 1,
|
|
||||||
trimmedSnippet(line),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
++scannedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success = true;
|
|
||||||
if (result.textMatches.isEmpty())
|
|
||||||
{
|
|
||||||
result.message = QStringLiteral("没有找到匹配的文本。");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
QStringList lines;
|
|
||||||
lines.append(QStringLiteral("文本搜索:%1").arg(normalizedKeyword));
|
|
||||||
for (int index = 0; index < result.textMatches.size(); ++index)
|
|
||||||
{
|
|
||||||
const WorkspaceTextMatch &match = result.textMatches.at(index);
|
|
||||||
lines.append(QStringLiteral("%1. %2:%3 %4")
|
|
||||||
.arg(index + 1)
|
|
||||||
.arg(match.relativePath)
|
|
||||||
.arg(match.lineNumber)
|
|
||||||
.arg(match.lineText));
|
|
||||||
}
|
|
||||||
result.message = QStringLiteral("找到 %1 条匹配。").arg(result.textMatches.size());
|
|
||||||
result.outputText = lines.join(QLatin1Char('\n'));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user