From 90262aaf6ddcc959d279f93e988be834203e1606 Mon Sep 17 00:00:00 2001 From: Ysm-04 Date: Fri, 17 Jul 2026 23:49:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=BA=9F=E5=BC=83=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=B9=B6=E5=AE=8C=E5=96=84=E6=B5=8B=E8=AF=95=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=AE=BE=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CMakeLists.txt | 50 - README.dev.md | 115 +- README.md | 41 +- docs/QtDesktopPet_后续功能规划与结构审查.md | 124 +- docs/QtDesktopPet_测试清单与验收标准.md | 125 +- docs/README.md | 7 +- .../DesktopPet_本地工作区Agent_施工计划_已终止.md} | 2 + ...DesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md} | 4 +- docs/performance_stability_check.md | 41 +- .../2026-07-17_pre-release_d0d1de4-dirty.md | 66 + ...6-07-17_qa-infrastructure_d0d1de4-dirty.md | 48 + docs/test-records/README.md | 27 + docs/test-records/TEMPLATE.md | 64 + docs/test-records/index.md | 8 + internal/qa/CMakeLists.txt | 64 + internal/qa/README.md | 61 + .../qa/config/performance-thresholds.json | 22 + internal/qa/config/static-checks.json | 59 + .../qa/fixtures/performance/sample-stable.csv | 4 + internal/qa/include/TestHarness.h | 68 ++ internal/qa/scripts/Invoke-StaticChecks.ps1 | 321 +++++ internal/qa/scripts/New-PerformanceReport.ps1 | 203 ++++ .../qa/scripts}/perf_sample.ps1 | 133 +-- internal/qa/tests/TestMain.cpp | 44 + .../qa/tests/ai/ConversationStoreTests.cpp | 118 ++ .../qa/tests/assistant/IntentRoutingTests.cpp | 42 + internal/qa/tests/reminder/ReminderTests.cpp | 105 ++ internal/qa/tests/weather/WeatherTests.cpp | 85 ++ internal/qa/tests/web/WebCapabilityTests.cpp | 65 + src/ai/ConversationManager.cpp | 61 +- src/ai/ConversationManager.h | 1 - src/assistant/CommandDispatcher.cpp | 57 +- src/assistant/CommandDispatcher.h | 6 - src/assistant/IntentRouter.cpp | 365 ------ src/assistant/UserIntent.h | 3 - src/character/CharacterPackageRepository.cpp | 13 +- src/character/CharacterPackageRepository.h | 2 - src/character/FrameAnimator.cpp | 5 - src/character/FrameAnimator.h | 1 - src/config/AppConfig.h | 3 - src/config/ConfigManager.cpp | 43 - src/config/ConfigManager.h | 1 - src/fileops/FileBackupManager.cpp | 49 - src/fileops/FileBackupManager.h | 14 - src/fileops/FileOperationManager.cpp | 208 ---- src/fileops/FileOperationManager.h | 27 - src/fileops/FileOperationTypes.h | 34 - src/fileops/FileSandbox.cpp | 212 ---- src/fileops/FileSandbox.h | 14 - src/launcher/AppDiscovery.cpp | 194 --- src/launcher/AppDiscovery.h | 16 - src/launcher/AppLaunchManager.cpp | 291 ----- src/launcher/AppLaunchManager.h | 25 - src/launcher/AppLaunchStore.cpp | 302 ----- src/launcher/AppLaunchStore.h | 17 - src/launcher/AppLaunchTypes.h | 45 - src/system/GlobalHotkeyManager.cpp | 209 ---- src/system/GlobalHotkeyManager.h | 30 - src/tray/TrayController.cpp | 15 - src/tray/TrayController.h | 1 - src/ui/ChatBubble.cpp | 44 +- src/ui/ChatBubble.h | 6 +- src/ui/PetWindow.cpp | 858 +------------ src/ui/PetWindow.h | 30 - src/ui/SettingsDialog.cpp | 456 ------- src/ui/SettingsDialog.h | 27 - src/ui/WorkspaceAgentWindow.cpp | 676 ----------- src/ui/WorkspaceAgentWindow.h | 89 -- src/ui/WorkspacePanel.cpp | 506 -------- src/ui/WorkspacePanel.h | 67 -- src/util/ResourcePaths.cpp | 16 - src/util/ResourcePaths.h | 2 - src/workspace/WorkspaceAccessPolicy.cpp | 371 ------ src/workspace/WorkspaceAccessPolicy.h | 36 - src/workspace/WorkspaceAgent.cpp | 36 - src/workspace/WorkspaceAgent.h | 11 - src/workspace/WorkspaceController.cpp | 1061 ----------------- src/workspace/WorkspaceController.h | 52 - src/workspace/WorkspaceIndex.cpp | 137 --- src/workspace/WorkspaceIndex.h | 23 - src/workspace/WorkspaceScanner.cpp | 125 -- src/workspace/WorkspaceScanner.h | 22 - src/workspace/WorkspaceSession.cpp | 90 -- src/workspace/WorkspaceSession.h | 44 - src/workspace/WorkspaceTool.h | 15 - src/workspace/WorkspaceTypes.h | 97 -- src/workspace/tools/ListFilesTool.cpp | 52 - src/workspace/tools/ListFilesTool.h | 15 - src/workspace/tools/OpenDirectoryTool.cpp | 56 - src/workspace/tools/OpenDirectoryTool.h | 18 - src/workspace/tools/OpenFileTool.cpp | 85 -- src/workspace/tools/OpenFileTool.h | 18 - src/workspace/tools/ReadFileTool.cpp | 69 -- src/workspace/tools/ReadFileTool.h | 18 - .../tools/RevealInFileManagerTool.cpp | 75 -- src/workspace/tools/RevealInFileManagerTool.h | 18 - src/workspace/tools/SearchFilesTool.cpp | 39 - src/workspace/tools/SearchFilesTool.h | 15 - src/workspace/tools/SearchTextTool.cpp | 102 -- src/workspace/tools/SearchTextTool.h | 17 - tools/prepare_github_export.ps1 | 10 + 102 files changed, 1711 insertions(+), 8175 deletions(-) rename docs/{agent/DesktopPet_本地工作区Agent_施工计划.md => archive/DesktopPet_本地工作区Agent_施工计划_已终止.md} (99%) rename docs/{QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md => archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md} (99%) create mode 100644 docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md create mode 100644 docs/test-records/2026-07-17_qa-infrastructure_d0d1de4-dirty.md create mode 100644 docs/test-records/README.md create mode 100644 docs/test-records/TEMPLATE.md create mode 100644 docs/test-records/index.md create mode 100644 internal/qa/CMakeLists.txt create mode 100644 internal/qa/README.md create mode 100644 internal/qa/config/performance-thresholds.json create mode 100644 internal/qa/config/static-checks.json create mode 100644 internal/qa/fixtures/performance/sample-stable.csv create mode 100644 internal/qa/include/TestHarness.h create mode 100644 internal/qa/scripts/Invoke-StaticChecks.ps1 create mode 100644 internal/qa/scripts/New-PerformanceReport.ps1 rename {tools => internal/qa/scripts}/perf_sample.ps1 (50%) create mode 100644 internal/qa/tests/TestMain.cpp create mode 100644 internal/qa/tests/ai/ConversationStoreTests.cpp create mode 100644 internal/qa/tests/assistant/IntentRoutingTests.cpp create mode 100644 internal/qa/tests/reminder/ReminderTests.cpp create mode 100644 internal/qa/tests/weather/WeatherTests.cpp create mode 100644 internal/qa/tests/web/WebCapabilityTests.cpp delete mode 100644 src/fileops/FileBackupManager.cpp delete mode 100644 src/fileops/FileBackupManager.h delete mode 100644 src/fileops/FileOperationManager.cpp delete mode 100644 src/fileops/FileOperationManager.h delete mode 100644 src/fileops/FileOperationTypes.h delete mode 100644 src/fileops/FileSandbox.cpp delete mode 100644 src/fileops/FileSandbox.h delete mode 100644 src/launcher/AppDiscovery.cpp delete mode 100644 src/launcher/AppDiscovery.h delete mode 100644 src/launcher/AppLaunchManager.cpp delete mode 100644 src/launcher/AppLaunchManager.h delete mode 100644 src/launcher/AppLaunchStore.cpp delete mode 100644 src/launcher/AppLaunchStore.h delete mode 100644 src/launcher/AppLaunchTypes.h delete mode 100644 src/system/GlobalHotkeyManager.cpp delete mode 100644 src/system/GlobalHotkeyManager.h delete mode 100644 src/ui/WorkspaceAgentWindow.cpp delete mode 100644 src/ui/WorkspaceAgentWindow.h delete mode 100644 src/ui/WorkspacePanel.cpp delete mode 100644 src/ui/WorkspacePanel.h delete mode 100644 src/workspace/WorkspaceAccessPolicy.cpp delete mode 100644 src/workspace/WorkspaceAccessPolicy.h delete mode 100644 src/workspace/WorkspaceAgent.cpp delete mode 100644 src/workspace/WorkspaceAgent.h delete mode 100644 src/workspace/WorkspaceController.cpp delete mode 100644 src/workspace/WorkspaceController.h delete mode 100644 src/workspace/WorkspaceIndex.cpp delete mode 100644 src/workspace/WorkspaceIndex.h delete mode 100644 src/workspace/WorkspaceScanner.cpp delete mode 100644 src/workspace/WorkspaceScanner.h delete mode 100644 src/workspace/WorkspaceSession.cpp delete mode 100644 src/workspace/WorkspaceSession.h delete mode 100644 src/workspace/WorkspaceTool.h delete mode 100644 src/workspace/WorkspaceTypes.h delete mode 100644 src/workspace/tools/ListFilesTool.cpp delete mode 100644 src/workspace/tools/ListFilesTool.h delete mode 100644 src/workspace/tools/OpenDirectoryTool.cpp delete mode 100644 src/workspace/tools/OpenDirectoryTool.h delete mode 100644 src/workspace/tools/OpenFileTool.cpp delete mode 100644 src/workspace/tools/OpenFileTool.h delete mode 100644 src/workspace/tools/ReadFileTool.cpp delete mode 100644 src/workspace/tools/ReadFileTool.h delete mode 100644 src/workspace/tools/RevealInFileManagerTool.cpp delete mode 100644 src/workspace/tools/RevealInFileManagerTool.h delete mode 100644 src/workspace/tools/SearchFilesTool.cpp delete mode 100644 src/workspace/tools/SearchFilesTool.h delete mode 100644 src/workspace/tools/SearchTextTool.cpp delete mode 100644 src/workspace/tools/SearchTextTool.h diff --git a/.gitignore b/.gitignore index c0189a8..3fe1314 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ cmake-build-*/ /config/ /logs/ /reports/perf/ +/reports/qa/ +/internal/qa/out/ *.broken.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 194af61..63455f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,8 +7,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_AUTOMOC OFF) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets Network Multimedia) @@ -50,20 +48,6 @@ qt_add_executable(QtDesktopPet src/config/ConfigManager.cpp src/config/SecretStore.h src/config/SecretStore.cpp - src/fileops/FileBackupManager.h - src/fileops/FileBackupManager.cpp - src/fileops/FileOperationManager.h - src/fileops/FileOperationManager.cpp - src/fileops/FileOperationTypes.h - src/fileops/FileSandbox.h - src/fileops/FileSandbox.cpp - src/launcher/AppDiscovery.h - src/launcher/AppDiscovery.cpp - src/launcher/AppLaunchManager.h - src/launcher/AppLaunchManager.cpp - src/launcher/AppLaunchStore.h - src/launcher/AppLaunchStore.cpp - src/launcher/AppLaunchTypes.h src/notification/NotificationDispatcher.h src/notification/NotificationDispatcher.cpp src/reminder/ReminderCommandHandler.h @@ -82,8 +66,6 @@ qt_add_executable(QtDesktopPet src/reminder/ReminderTypes.cpp src/state/PetStateMachine.h src/state/PetStateMachine.cpp - src/system/GlobalHotkeyManager.h - src/system/GlobalHotkeyManager.cpp src/system/StartupManager.h src/system/StartupManager.cpp src/tray/TrayController.h @@ -98,10 +80,6 @@ qt_add_executable(QtDesktopPet src/ui/PetView.cpp src/ui/SettingsDialog.h 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.cpp src/util/Logger.h @@ -118,34 +96,6 @@ qt_add_executable(QtDesktopPet src/weather/WeatherSummaryFormatter.h src/weather/WeatherSummaryFormatter.cpp 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.cpp src/web/WebChatManager.h diff --git a/README.dev.md b/README.dev.md index 533870d..4fa01cf 100644 --- a/README.dev.md +++ b/README.dev.md @@ -40,9 +40,7 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目 - 提醒到点气泡提示、稍后提醒、拖动后延迟提示和隐藏时托盘通知 - 提醒音效切换、试听、用户 wav 导入和删除 - 天气查询 v1:Open-Meteo 天气源、城市解析、默认城市、公网 IP 定位兜底和模板回复 -- 本地文件操作 v1:读取文本文件、列出文件夹、复制、备份、重命名 - 联网模式:输入框开关触发 AI Provider 原生联网能力,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding -- 应用启动 v1:通过聊天打开已登记应用、开始菜单快捷方式或用户确认选择的 `.exe` - Windows 发布打包脚本和 Inno Setup 安装器脚本 - Windows GUI 子系统,Release exe 双击不弹控制台窗口 @@ -50,9 +48,11 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物项目 - 长期性能压测记录 - 发布包实机安装/卸载验证 -- 文件操作 zip 打包、删除、覆盖、移动、脚本/命令执行 - 搜索网页全文抓取、长期缓存和浏览器自动打开网页 -- 应用启动脚本、命令行参数、管理员权限和跨平台应用发现 + +当前非目标: + +- 本地文件访问、应用启动、命令执行和工作区 Agent;这些能力已从当前产品方向移除 ## 技术栈 @@ -292,71 +292,6 @@ QStandardPaths::AppDataLocation/sounds/reminders/ - 每月 31 号这类规则会跳过不存在该日期的月份,寻找下一个有效月份 - 用户音效删除仅允许删除用户音效目录内的安全 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 或自建联网后端。 - 更细粒度的来源证据摘录和引用 UI。 -## 应用启动 +## 当前能力边界 -应用启动是独立能力,不归入本地文件操作。可通过聊天输入: +为保持桌宠产品聚焦,当前版本已移除聊天驱动的本地文件操作、应用启动和工作区 Agent。相关聊天内容只会进入普通 AI 对话或现有的提醒/天气意图,不会弹出本地路径选择器、启动程序、扫描项目或执行命令。 -```text -打开 Codex -启动酷狗音乐 -帮我打开 VSCode -``` +应用自身的“开机自启动”仍保留,由设置页和安装器选项管理。旧版 `launcher_config.json` 与 `app_config.json` 中的 `agent` 字段不会再读取或写入,但程序不会主动删除用户目录中的旧文件。 -配置保存到: - -```text -QStandardPaths::AppConfigLocation/launcher_config.json -``` - -设置页“应用启动”支持: - -- 启用或关闭应用启动 -- 未知应用允许用户手动选择 `.exe` -- 登记应用名称和别名 -- 编辑、删除、测试启动已登记应用 - -安全边界: - -- 启动前始终二次确认,确认内容包含应用名、路径、工作目录和来源 -- 只允许启动 `.exe` 或开始菜单 `.lnk` 快捷方式 -- 不执行 `.bat` / `.cmd` / `.ps1` / `.vbs` / `.js` / `.msi` -- 不执行聊天文本里的命令 -- 不拼接聊天文本参数 -- 不以管理员权限启动 -- 手选 `.exe` 后可选择记住为当前名称,后续走登记应用匹配 ## 配置和日志 @@ -437,7 +346,6 @@ ai_config.broken.yyyyMMdd-HHmmss.json conversation_history.broken.yyyyMMdd-HHmmss.json weather_config.broken.yyyyMMdd-HHmmss.json web_config.broken.yyyyMMdd-HHmmss.json -launcher_config.broken.yyyyMMdd-HHmmss.json ``` 日志输出到文件,不输出到控制台: @@ -550,7 +458,7 @@ C:\Program Files (x86)\Inno Setup 6\ISCC.exe 仓库提供开发用性能采样脚本,不进入普通用户发布包: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m ``` 默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条,CSV 输出到: @@ -565,6 +473,13 @@ reports/perf/ docs/performance_stability_check.md ``` +内部核心逻辑测试、静态检查、性能报告和长期测试记录说明见: + +```text +internal/qa/README.md +docs/test-records/README.md +``` + 发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/`、`release_packages/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`resources/sounds/`、`LICENSE` 和必要说明。 ## AI 配置和聊天 diff --git a/README.md b/README.md index 3ff2f24..c5b804c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 Geocoding API - 默认城市、公网 IP 定位兜底、多候选提示 -- 本地文件操作 v1: - - 读取文本文件 - - 列出文件夹 - - 复制、备份、重命名 - - 写操作前二次确认 -- 本地工作区 Agent v1b: - - 用户选择工作区后扫描并建立内存索引 - - 搜索文件名、搜索文本、读取工作区文本文件 - - 项目结构摘要和文件内容总结 - - 独立 Agent 窗口展示文件结果、文本命中、上下文、任务对话和详情预览 - - 支持右键菜单、托盘菜单和默认 `Alt+A` 呼出窗口 - - 打开工作区内普通文件、打开文件夹、显示文件所在位置 - - 不执行命令,不创建/修改/移动/删除文件 -- 应用启动 v1: - - 聊天触发打开本地应用 - - 支持已登记应用、开始菜单快捷方式和用户手选 `.exe` - - 启动前二次确认 - Windows 发布脚本和 Inno Setup 安装器脚本。 ## Platform 当前主要目标平台是 Windows 10 / Windows 11。 -项目中已有部分跨平台基础代码,但托盘通知、开机自启动、应用发现和安装器体验目前按 Windows 优先实现。 +项目中已有部分跨平台基础代码,但托盘通知、开机自启动和安装器体验目前按 Windows 优先实现。 ## Tech Stack @@ -83,8 +66,6 @@ │ ├── assistant/ # Intent routing and command dispatch │ ├── character/ # Character package loading and animation │ ├── config/ # Config persistence -│ ├── fileops/ # Local file operations -│ ├── launcher/ # Local application launcher │ ├── notification/ # Notification dispatch │ ├── reminder/ # Reminder parser/store/scheduler/sounds │ ├── state/ # Pet state machine @@ -93,9 +74,8 @@ │ ├── ui/ # Widgets and main pet window │ ├── util/ │ ├── weather/ -│ ├── web/ # AI-native web mode -│ └── workspace/ # Read-only local workspace agent -└── tools/ # Packaging and diagnostic scripts +│ └── web/ # AI-native web mode +└── tools/ # Packaging and public-export scripts ``` ## Build @@ -155,7 +135,6 @@ Examples: - Reminders: `reminders.json` - Weather config: `weather_config.json` - Web mode config: `web_config.json` -- Launcher config: `launcher_config.json` The app writes rotating logs under: @@ -177,14 +156,10 @@ Important notes: ## Safety Boundaries -The project intentionally keeps local automation conservative: - -- File operations require user-selected paths. -- Write operations require confirmation. -- File operations do not execute scripts or commands. -- Application launch only supports `.exe` and Start Menu `.lnk` shortcuts. -- Chat text is not converted into shell commands. -- Startup integration writes only the current user's Windows `Run` entry. +- The application does not read, search, create, modify, move, or delete local files through chat. +- It does not launch local applications or convert chat text into shell commands or scripts. +- Windows startup integration only manages this application's current-user `Run` entry. +- AI and web requests are sent only to providers configured by the user. ## Web Mode diff --git a/docs/QtDesktopPet_后续功能规划与结构审查.md b/docs/QtDesktopPet_后续功能规划与结构审查.md index 89c85e9..0670ee5 100644 --- a/docs/QtDesktopPet_后续功能规划与结构审查.md +++ b/docs/QtDesktopPet_后续功能规划与结构审查.md @@ -1,104 +1,70 @@ # 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/assistant/` | 已落地 | -| 角色包 | `src/character/` | 已落地 | -| 配置与密钥 | `src/config/` | 已落地 | -| 文件操作 v1 | `src/fileops/` | 已落地,后续将被 Workspace Agent 替代 | -| 应用启动 | `src/launcher/` | 已落地 | -| 通知 | `src/notification/` | 已落地 | -| 提醒 | `src/reminder/` | 已落地 | -| 状态机 | `src/state/` | 已落地 | -| 系统能力 | `src/system/` | 已落地 | -| 天气 | `src/weather/` | 已落地 | -| 联网模式 | `src/web/` | 已落地 | -| 本地工作区 Agent | `src/workspace/` + `src/ui/WorkspaceAgentWindow.*` | v1a.1/v1b/v1b.5 独立窗口化已落地 | +| 桌宠与动画 | `src/ui/`、`src/character/`、`src/state/` | 透明窗口、拖动、托盘、单实例、多状态动画、懒加载和缓存管理已落地 | +| AI 对话 | `src/ai/` | OpenAI-compatible、Gemini、DeepSeek/Custom 配置、流式输出、取消和历史管理已落地 | +| 联网模式 | `src/web/` | OpenAI 官方 Web Search 与 Gemini Google Search grounding 已落地;不支持 Provider 明确提示 | +| 提醒 | `src/reminder/`、`src/notification/` | 一次性和日/周/月重复、编辑 pending、20 天历史、音效、通知和兜底调度已落地 | +| 天气 | `src/weather/` | Open-Meteo、默认城市、IP fallback、多候选提示和默认城市测试已落地 | +| 角色管理 | `src/character/`、设置页 | 导入、验证、切换、删除用户角色、导出和打开用户角色目录已落地 | +| 配置与发布 | `src/config/`、`src/system/`、`tools/`、`installer/` | 原子/兼容配置、开机自启动、打包和安装脚本已落地 | -`PetWindow` 仍然承担较多 UI 编排职责。后续新增大功能必须优先放在独立模块和 Controller 中,`PetWindow` 只保留入口、展示和确认交互。 +## 已移除模块 -## 下一阶段主线 +以下模块已从源码、CMake、意图分发、设置页和托盘入口中删除: -下一阶段主线仍是本地工作区 Agent,而不是继续扩展旧 `src/fileops/`。 +- 聊天驱动的本地文件操作。 +- 本地应用启动与应用登记。 +- 本地工作区 Agent、独立 Agent 窗口和 `Alt+A` 全局快捷键。 -v1a.1/v1b/v1b.5 已完成: +删除原因是这些能力显著扩大安全边界和维护面,并使桌宠产品偏离轻量助手定位。应用自身的“开机自启动”不属于应用启动模块,继续保留。 -- 用户选择一个本地工作区目录。 -- 桌宠可以扫描、搜索、读取和分析该工作区内文件。 -- 桌宠可以打开工作区内普通文件、打开文件夹、显示文件所在位置。 -- 独立 Agent 窗口可以展示文件结果、文本命中、最近读取、用户任务、本地工具结果和 AI 分析结果,并对选中结果执行读取、打开、显示位置。 -- 写操作误分发已收口:创建/新建/写入/修改不会再误走旧 fileops 的读取文本文件流程。 -- 文本搜索误分发已收口:`含class的文件 / 包含 class 的文件 / 找一下含 class 的文件` 会走工作区全文搜索,不再弹旧文本文件选择框。 +兼容策略:程序不主动删除用户目录中的旧 `launcher_config.json`;旧 `app_config.json` 中的 `agent` 对象会被忽略,后续保存时不再写回。 -后续目标: +## 当前意图边界 -- 桌宠可以生成文件修改计划。 -- 创建、修改、复制、移动、重命名、删除到回收站等写操作必须先展示计划并等待用户确认。 -- 不允许访问工作区外路径。 -- 不允许执行系统命令、脚本或聊天文本中的命令参数。 -- 不允许永久删除文件。 +聊天意图优先级固定为: -正式施工计划见: +```text +Reminder > Weather > Chat/WebChat +``` -`docs/agent/DesktopPet_本地工作区Agent_施工计划.md` +- 含“提醒”等明确提醒表达时进入提醒模块。 +- 含天气、气温、降雨等表达时进入天气模块。 +- 其余内容进入普通 AI 对话;输入框联网开关开启且 Provider 支持时进入联网对话。 +- “读取文件”“打开 Codex”“分析这个项目”等文本不会触发本地系统能力。 -## 功能优先级建议 +## 下一阶段建议 -当前聊天意图建议保持以下原则: +优先完成稳定性和发布收口,不再横向扩展高权限本地自动化: -1. 提醒明确优先,例如“明天提醒我看天气”应创建提醒。 -2. 天气明确问答走天气模块,例如“明天西安天气怎么样”。 -3. 工作区相关文件和代码任务走 Workspace Agent,例如“搜索这个项目里的 README”“读取 main.cpp”“分析这个项目”;重命名等写操作留到后续阶段。 -4. 明确应用启动走 Launcher,例如“打开 Codex”“启动酷狗音乐”。 -5. 其余走普通聊天或联网聊天。 +1. 完成全量手动回归,重点覆盖提醒调度、联网 Provider 差异、天气失败路径和旧配置兼容。 +2. 完成 Release 包、安装、卸载、开机自启动和无 Qt 开发环境运行验证。 +3. 完成静置、隐藏、动画缓存、AI 连续对话、提醒、天气和联网请求的性能采样。 +4. 根据实测问题修复稳定性,不新增复杂提醒规则、网页抓取或高权限本地能力。 +5. 确认角色、图标、音效素材的公开再分发授权。 -不能简单用“文件 / 打开 / 搜索”这类宽关键词吞掉所有请求。尤其要避免: +## 延期或非目标 -- “打开微信”误判为工作区打开文件。 -- “搜索 Qt 最新版本”误判为工作区搜索。 -- “把天气截图保存到桌面”误判为天气查询。 - -## 当前延期项 - -以下能力继续延期,不进入本地工作区 Agent 第一阶段: - -- 语音输入和语音朗读。 -- Live2D、GIF、视频形象。 -- 插件系统。 -- 云同步和自动更新。 -- 长期记忆系统。 -- 角色市场或角色包分享平台。 -- Shell 命令执行、脚本执行、自动安装依赖。 -- 自动 git commit、自动推送、自动联网下载并执行文件。 -- 工作区外全盘搜索。 -- 复杂网页抓取和旧搜索引擎聚合。 +- 语音输入输出、Live2D、GIF/视频角色。 +- 插件系统、云同步、自动更新、长期记忆和角色市场。 +- 空气质量、天气预警、国内天气 Provider 和天气提醒联动。 +- 复杂提醒规则、跨平台通知和自定义稍后提醒间隔。 +- 网页全文抓取、搜索引擎页面解析、自建结构化搜索后端。 +- 本地文件访问、应用启动、命令执行和工作区 Agent。 ## 验收策略 -- Codex 不调用 CMake、`cmake --build`、Qt Creator 构建或等价构建命令。 -- 每完成一个模块,先做静态检查和逻辑推演。 - 编译和运行验证由用户手动执行。 -- 功能验收按 `docs/QtDesktopPet_测试清单与验收标准.md`。 -- 性能和发布验收按 `docs/performance_stability_check.md`。 +- 统一测试入口见 `docs/QtDesktopPet_测试清单与验收标准.md`。 +- 性能与发布验证入口见 `docs/performance_stability_check.md`。 +- 每次能力调整后必须同步 README、测试清单、CMake 和设置页,避免残留不可达代码。 diff --git a/docs/QtDesktopPet_测试清单与验收标准.md b/docs/QtDesktopPet_测试清单与验收标准.md index 2dc6c4c..d0a07c4 100644 --- a/docs/QtDesktopPet_测试清单与验收标准.md +++ b/docs/QtDesktopPet_测试清单与验收标准.md @@ -2,6 +2,8 @@ 本文档用于全功能统一手测验收。Codex 不调用 CMake 或构建命令;需要编译验证时由用户手动构建并反馈结果。 +每次实际执行结果保存到 `docs/test-records/`,不得直接用某次结果覆盖本验收标准。内部自动化入口见 `internal/qa/README.md`。 + ## 验收记录格式 每个用例建议记录: @@ -24,7 +26,7 @@ Qt 版本: - 日志不包含完整 API Key、Authorization、完整用户消息正文、完整错误响应正文或完整搜索词。 - 设置页保存后立即生效,重启后配置仍能读取。 - 所有损坏 JSON 配置都应备份为 `.broken.yyyyMMdd-HHmmss.json` 后回退默认或忽略损坏数据。 -- 所有涉及本地文件写入的操作必须由用户选择路径,并在执行前显示计划和二次确认。 +- 角色导入/导出、音效导入和历史导出只能使用用户明确选择的路径;覆盖或删除用户数据时必须确认。 - 不运行 CMake 的自动验证;构建和运行由用户手动完成。 ## 基础窗口与托盘 @@ -37,6 +39,8 @@ Qt 版本: | 隐藏托盘 | 托盘菜单隐藏/显示 | 隐藏后动画暂停,显示后恢复 | TODO | | 单实例 | 已运行时再次启动 exe | 只有一个进程,已有实例被唤醒 | TODO | | 设置页居中 | 多屏或重复打开设置页 | 设置页出现在当前屏幕可用区域内并置前 | TODO | +| 应用设置持久化 | 修改缩放、性能模式、隐藏暂停、懒加载和缓存设置后重启 | 设置立即生效,重启后保持,动画和窗口尺寸无异常 | TODO | +| 开机自启动 | 在应用页依次开启、重启检查、再关闭 | 开启时写入当前用户 Run 项且路径正确;关闭时移除;默认关闭 | TODO | ## 角色管理 @@ -59,7 +63,7 @@ Qt 版本: | 连接测试 | 点击测试连接 | 成功显示成功;错误配置显示明确错误 | TODO | | 普通聊天 | 发送短消息 | `think` 后进入 `talk`,气泡显示回复 | TODO | | 流式回复 | 启用流式并发送消息 | 首段前保持 `think`,输出中保持 `talk` | TODO | -| AI 忙 | 回复中再次发送聊天/天气/搜索 | 不启动第二个请求,显示忙提示 | TODO | +| AI 忙 | 回复中再次发送聊天/天气/联网请求 | 不启动第二个请求,显示忙提示 | TODO | | 取消 AI | 右键取消 AI 请求 | 请求取消,状态恢复,气泡提示取消 | TODO | | 历史保存 | 开启本地保存后聊天 | 重启后历史可读取 | TODO | | 历史搜索 | 在设置页按关键词搜索 | 只显示匹配记录 | TODO | @@ -67,6 +71,7 @@ Qt 版本: | 导出 Markdown | 导出筛选结果为 `.md` | 文件包含角色、时间、Provider/模型和正文,不含 API Key | TODO | | 导出 JSON | 导出筛选结果为 `.json` | JSON 可解析,包含 messages 数组,不含 API Key | TODO | | 清空历史 | 点击清空聊天记录并确认 | 内存和本地历史清空,面板刷新 | TODO | +| 关闭历史保存 | 关闭本地保存后聊天并重启 | 本轮消息只在内存存在,重启后不写入本地历史 | TODO | ## 定时提醒 @@ -90,6 +95,8 @@ Qt 版本: | 历史清理 | 构造 21 天前 triggered/canceled 后清理 | 删除 20 天前历史,保留最近 20 天和 pending | TODO | | 音效导入 | 导入合法 PCM wav | 可选择、试听、删除 | TODO | | 内置音效保护 | 选择内置音效点击删除 | 删除按钮禁用或提示不可删除 | TODO | +| 睡眠与时间变化 | 睡眠跨过提醒时间;再分别模拟系统时间向前和向后变化 | 唤醒或向前跳变后补扫一次;向后跳变不提前触发 | TODO | +| 提醒保存失败 | 在隔离测试数据上模拟提醒文件不可写并等待到期 | 保持 pending,不播放、不通知、不显示气泡,后续仍可重试 | TODO | ## 天气查询 @@ -106,73 +113,20 @@ Qt 版本: | 默认城市测试 | 设置页点击测试默认城市 | 显示匹配城市/行政区/国家,不自动保存 | TODO | | 未知城市 | 输入无法解析城市 | 明确错误,不崩溃 | TODO | | 不支持项 | 查询空气质量/预警/穿衣指数 | 明确提示 v1 暂不支持 | TODO | +| 天气网络失败 | 断网或阻断天气请求后查询 | 明确提示网络/超时错误,UI 不阻塞,恢复网络后可再次查询 | TODO | -## 本地文件操作 - -当前源码仍保留安全受限的本地文件操作 v1。后续将由本地工作区 Agent 替代;替代完成前,以下用例仍作为回归项保留。 +## 已移除本地能力回归 | 用例 | 步骤 | 预期结果 | 结论 | | --- | --- | --- | --- | -| 读取文本 | 输入读取文本文件请求并选择 txt/md/log | 显示内容预览,最多读取前 64KB | TODO | -| 非文本拒绝 | 选择 exe/png 等非文本 | 提示只允许常见文本文件 | TODO | -| 列目录 | 输入列出文件夹请求并选择临时目录 | 显示前 200 项,区分目录/文件 | TODO | -| 复制文件 | 选择源文件和目标目录 | 显示计划,确认后复制;目标存在则拒绝覆盖 | TODO | -| 创建备份 | 选择文件备份 | 显示计划,确认后生成 `.backup.yyyyMMdd-HHmmss` 文件 | TODO | -| 重命名 | 选择文件并输入新文件名 | 显示计划,确认后重命名;目标存在拒绝 | TODO | -| 危险词拒绝 | 输入删除/覆盖/移动/执行脚本/运行命令 | 明确拒绝,不弹路径选择,不做文件操作 | TODO | -| 系统目录拒绝 | 尝试选择 Windows/Program Files 下路径 | 提示不允许访问系统目录 | TODO | -| 符号链接拒绝 | 选择符号链接或链接目录内路径 | 提示不允许操作符号链接路径 | TODO | -| zip 延期 | 输入打包/压缩请求 | 提示 zip 打包暂不启用 | TODO | +| 文件操作已移除 | 输入 `读取文件`、`创建 test.txt`、`复制文件` | 不弹文件选择框,不读取或修改本地文件;按普通 AI 对话处理 | TODO | +| 应用启动已移除 | 输入 `打开 Codex`、`启动酷狗音乐` | 不弹 exe 选择框或确认框,不启动任何程序;按普通 AI 对话处理 | TODO | +| 工作区 Agent 已移除 | 输入 `打开工作区`、`分析这个项目`、`含 class 的文件` | 不扫描目录、不弹 Agent 窗口、不访问本地项目;按普通 AI 对话处理 | TODO | +| 菜单入口清理 | 检查桌宠右键菜单和托盘菜单 | 不再出现 `Agent 工作区` 入口 | TODO | +| 设置页清理 | 打开设置页 | 不再出现 `应用启动` 和 `Agent` 页面;应用页仍有 `开机自启动` | TODO | +| 快捷键清理 | 按 `Alt+A` | 不再呼出 Agent 窗口,不影响其他快捷键或输入 | TODO | +| 旧配置兼容 | 用户目录保留旧 `launcher_config.json`,`app_config.json` 保留 `agent` 对象后启动 | 程序不崩溃,不读取旧能力配置;保存应用配置后不再写入 `agent` | TODO | -## 本地工作区 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 | | 设置页测试 | 点击“测试联网模式” | 支持 Provider 发起测试,不支持 Provider 直接显示原因 | TODO | | 旧失败回归 | 打开联网后询问 `美国的首都在哪里` | 不再返回搜狗/360 帮助、登录、反馈页 | TODO | -| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 仍优先走提醒或文件操作,不受联网开关影响 | TODO | +| 意图优先级 | `明天早上提醒我看天气` / `把天气截图保存到桌面` | 前者走提醒;后者可走天气或普通对话,但绝不触发本地文件保存 | TODO | +| 联网开关双向记忆 | 启用记忆后分别执行关到开、开到关,不发送消息直接关闭并重开输入框 | 两个方向都保存最后状态;关闭记忆后使用默认开关值 | TODO | +| 联网网络失败 | 支持 Provider 下模拟断网、超时或服务端错误 | 明确提示失败,状态恢复,可重新发起请求,不泄露完整错误正文 | TODO | -## 应用启动 +## 配置兼容与隐私 + +所有损坏测试都必须先备份用户数据,优先使用隔离测试账户或临时配置副本。 | 用例 | 步骤 | 预期结果 | 结论 | | --- | --- | --- | --- | -| 设置页登记 | 添加一个 `.exe`,填写名称和别名后保存 | 重启后登记项仍存在 | TODO | -| 已登记启动 | 输入 `打开 <别名>` | 弹出确认框,确认后启动对应 exe | TODO | -| 未知应用手选 | 输入 `打开酷狗音乐` 且未登记 | 弹出 exe 选择器,选择 `.exe` 后弹确认框 | TODO | -| 记住手选应用 | 手选 exe 时勾选记住 | 后续同名请求直接命中登记应用并仍需确认 | TODO | -| 用户取消选择 | 未知应用弹出选择器后取消 | 不启动任何程序,无配置写入 | TODO | -| 用户取消确认 | 选择或命中应用后取消确认 | 不启动任何程序 | TODO | -| 拒绝脚本 | 尝试选择 `.bat/.cmd/.ps1/.vbs/.js/.msi` | 被拒绝,不启动 | TODO | -| 不执行命令 | 输入“运行 powershell 命令” | 不执行聊天文本命令;按安全规则拒绝或走普通聊天 | TODO | -| 文件操作优先 | 输入 `打开文件夹` | 走文件操作或文件操作拒绝路径,不走应用启动 | TODO | -| 设置页测试启动 | 选中登记应用点测试启动 | 先二次确认,确认后启动 | TODO | -| 设置页开机自启动 | 应用设置页勾选/取消“开机自启动”并保存 | 当前用户 Run 注册表项写入/移除,重开设置页状态与实际注册表一致 | TODO | +| App 配置损坏 | 写入非法 `app_config.json` 后启动 | 原文件备份为带时间戳的 broken 文件,程序使用默认配置启动 | TODO | +| AI 配置损坏 | 写入非法 `ai_config.json` 后启动 | 备份损坏文件,AI 回退未配置状态,不泄露旧 Key | TODO | +| 天气配置损坏 | 写入非法 `weather_config.json` 后启动 | 备份损坏文件,天气使用默认配置 | TODO | +| 联网配置损坏 | 写入非法 `web_config.json` 后启动 | 备份损坏文件,联网开关使用默认配置 | TODO | +| 提醒数据损坏 | 写入非法 `reminders.json` 后启动 | 备份损坏文件,不崩溃、不触发错误数据 | TODO | +| 历史数据损坏 | 写入非法 `conversation_history.json` 后打开历史 | 备份损坏文件并忽略,历史面板可继续使用 | TODO | +| 日志隐私 | 覆盖正常请求、错误 Key、错误 Base URL、天气和联网失败 | 日志不含 API Key、Authorization、完整消息或完整错误响应 | TODO | + +## 自动化验证 + +内部 QA 与发布构建隔离。用户配置好 Qt 测试构建后执行核心逻辑测试;不需要编译的静态检查可直接执行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1 +``` + +当前自动化覆盖意图分发、提醒解析和类型、天气解析和模板、联网能力检测、聊天历史兼容,以及源码登记、废弃模块残留、默认资源和 GitHub 导出隔离。真实窗口、托盘通知、音效、网络 Provider、安装卸载仍按手测执行。 + ## 发布与性能 @@ -244,8 +209,6 @@ UI 状态: | 提醒 | `CommandDispatcher -> ReminderCommandHandler -> ReminderManager` | 创建/查询/取消一次性和 daily/weekly/monthly;到点先保存 JSON,再播放音效和展示/通知 | 保存失败回滚内存,不播放、不通知、不气泡;隐藏和 AI 忙只发系统通知;拖动中进入可见队列;20 天前历史可清理 | 系统通知是否真正弹出受 Windows/托盘后端影响,Qt 只能确认发起状态 | v2 行为保持,复杂重复规则、跨平台通知、自定义稍后间隔延期 | | 角色 | 设置页角色页和 `CharacterPackageRepository` | 导入先验证再复制;切换保存后立即生效;用户角色可删除;任意角色可导出;可打开用户角色目录 | 内置角色不可删除/覆盖;导出目标存在需二次确认;损坏角色包不复制 | 目标目录权限失败、用户取消、角色包资源缺失 | 当前角色管理补全完成,角色市场/分享延期 | | 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 | -| 文件操作 | `CommandDispatcher -> PetWindow::handleFileOperationChatMessage() -> FileOperationManager` | 用户通过系统对话框选择路径;读取文本、列目录、复制、备份、重命名;写操作先展示计划并二次确认 | 删除/覆盖/移动/脚本/命令/截图保存/zip 直接拒绝;系统目录、符号链接、目标冲突、非法文件名均拒绝 | 文件选择对话框是安全边界;系统目录根据常见路径和环境变量识别 | v1 安全能力完成,zip 打包延期,不引入重依赖 | -| 本地工作区 Agent | `CommandDispatcher -> PetWindow::handleWorkspaceChatMessage() -> WorkspaceController -> WorkspaceAgent/WorkspaceTool -> WorkspaceAgentWindow` | v1b.5 用户选择工作区后,扫描索引、搜索文件/文本、读取文本文件、生成本地摘要,AI 可用时补简短分析,并支持打开普通文件、打开文件夹、显示所在位置、独立 Agent 窗口、菜单入口和全局快捷键 | 工作区外路径、系统目录、符号链接、危险后缀、敏感文件、命令执行均拒绝;打开文件前确认;AI 忙时不阻塞本地结果 | 路径归一化、符号链接逃逸、敏感文件判断、意图误判、全局快捷键冲突是核心风险;写操作仍未进入当前阶段 | v1a.1/v1b/v1b.5 独立窗口化已落地;低风险写操作计划留到后续 | +| 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 | | 联网模式 | `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 或构建命令 | diff --git a/docs/README.md b/docs/README.md index 8eab9fd..ed4c797 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ | `QtDesktopPet_后续功能规划与结构审查.md` | 当前功能状态、下一阶段路线和结构边界 | | `QtDesktopPet_测试清单与验收标准.md` | 功能测试清单、回归验收标准和静态推演记录 | | `performance_stability_check.md` | 性能采样、稳定性检查和发布验证入口 | -| `agent/DesktopPet_本地工作区Agent_施工计划.md` | 本地工作区 Agent 的正式施工计划 | +| `test-records/` | 每次回归、性能和候选发布的长期测试记录 | ## 归档文档 @@ -20,7 +20,9 @@ | `archive/implementation_plan.md` | 早期分阶段实施计划,当前大部分阶段已完成或被后续模块替代 | | `archive/Qt_DesktopPet_开发文档.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、测试清单和性能检查项。 - 历史文档不再直接修改,除非补充归档说明。 - GitHub 公开导出不包含 `docs/`;开发仓库保留完整文档。 +- 内部自动化测试位于 `internal/qa/`,只进入开发仓库,不进入 GitHub 公开导出和发布包。 - 在构建卡住问题解决前,文档中的验证步骤仍以用户手动构建为准,不由 Codex 调用 CMake 或构建命令。 diff --git a/docs/agent/DesktopPet_本地工作区Agent_施工计划.md b/docs/archive/DesktopPet_本地工作区Agent_施工计划_已终止.md similarity index 99% rename from docs/agent/DesktopPet_本地工作区Agent_施工计划.md rename to docs/archive/DesktopPet_本地工作区Agent_施工计划_已终止.md index a4e5e11..46e746d 100644 --- a/docs/agent/DesktopPet_本地工作区Agent_施工计划.md +++ b/docs/archive/DesktopPet_本地工作区Agent_施工计划_已终止.md @@ -1,5 +1,7 @@ # DesktopPet 本地工作区 Agent 施工计划 +> 归档说明:该路线已于 2026-07-16 从 QtDesktopPet 当前产品方向中移除,本文仅保留历史设计,不再作为施工依据。 + ## 1. 定位 本地工作区 Agent 是下一阶段核心功能。它不是继续维护旧版“文件操作 v1”,而是新增一个独立的工作区系统: diff --git a/docs/QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md b/docs/archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md similarity index 99% rename from docs/QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md rename to docs/archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md index 8e898fb..293c7fb 100644 --- a/docs/QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md +++ b/docs/archive/QtDesktopPet_本地工作区Agent_独立窗口化施工文档_已终止.md @@ -1,5 +1,7 @@ # QtDesktopPet 本地工作区 Agent 独立窗口化施工文档 +> 归档说明:该路线已于 2026-07-16 从 QtDesktopPet 当前产品方向中移除,本文仅保留历史设计,不再作为施工依据。 + ## 0. 本次施工目标 当前项目已经有“本地工作区 Agent”面板雏形,能够显示: @@ -1227,4 +1229,4 @@ Agent 搜索、读取、分析文件 用户确认后执行 ``` -这是后续接近 Codex / Claude Code 工作流的基础。 \ No newline at end of file +这是后续接近 Codex / Claude Code 工作流的基础。 diff --git a/docs/performance_stability_check.md b/docs/performance_stability_check.md index 87e8b4b..7d221a4 100644 --- a/docs/performance_stability_check.md +++ b/docs/performance_stability_check.md @@ -22,13 +22,13 @@ 默认采样当前 `QtDesktopPet` 进程 5 分钟,每 5 秒一条: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Scenario idle-5m ``` 指定 PID 采样: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 12345 -DurationSeconds 600 +powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/perf_sample.ps1 -Pid 12345 -Scenario idle-10m -DurationSeconds 600 ``` 输出目录默认是: @@ -37,28 +37,34 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/perf_sample.ps1 -Pid 1 reports/perf/ ``` -`reports/perf/` 已加入 `.gitignore`。需要沉淀结论时,只把摘要写入本文档,不提交原始采样 CSV。 +`reports/perf/` 已加入 `.gitignore`。原始 CSV 不提交;使用报告脚本生成统计摘要,审核并脱敏后写入对应的 `docs/test-records/` 记录: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/New-PerformanceReport.ps1 ` + -InputPath reports/perf/.csv -Scenario idle-5m +``` + +默认只强制判定进程意外退出和无响应。CPU、内存、句柄和线程阈值保存在 `internal/qa/config/performance-thresholds.json`,取得稳定 Release 基线后再按场景填写。 + +下表中的 `perf_sample.ps1` 均指 `internal/qa/scripts/perf_sample.ps1`。 ## 检查项 | 场景 | 步骤 | 采样命令 | 预期结果 | 实际结果 | 结论 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 启动后静置 | 启动程序后不操作 5 分钟 | `tools/perf_sample.ps1` 默认参数 | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO | -| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `tools/perf_sample.ps1 -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO | +| 启动后静置 | 启动程序后不操作 5 分钟 | `perf_sample.ps1 -Scenario idle-5m` | CPU 保持低占用,内存无持续上涨 | TODO | TODO | TODO | +| idle 连续播放 | 保持桌宠可见并播放 idle 10 分钟 | `perf_sample.ps1 -Scenario idle-10m -DurationSeconds 600` | 动画持续播放,内存曲线稳定 | TODO | TODO | TODO | | 隐藏到托盘 | 可见采样 3 分钟,隐藏后再采样 3 分钟 | 两次采样分别记录 | 隐藏后 CPU 下降或保持低占用 | TODO | TODO | TODO | -| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `tools/perf_sample.ps1 -DurationSeconds 300` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO | +| 重复显示 / 隐藏 | 连续显示/隐藏 10 次 | `perf_sample.ps1 -Scenario show-hide` | 无崩溃,句柄数和内存无异常增长 | TODO | TODO | TODO | | 重复启动 / 单实例 | 已运行时连续双击 exe 多次;隐藏到托盘后再双击 exe | 任务管理器或 `Get-Process QtDesktopPet` | 始终只有一个进程;隐藏状态可被重复启动唤醒 | TODO | TODO | 检查 QLocalServer 激活路径 | | 设置页居中 / 置前 | 从托盘打开设置页;多屏环境重复打开;设置页已打开时再次启动 exe | 手工观察 | 设置页位于当前屏幕可用区域中心;重复启动时设置页置前 | TODO | TODO | 优先按宠物所在屏幕计算 | -| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `tools/perf_sample.ps1 -DurationSeconds 300` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 | -| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `tools/perf_sample.ps1 -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 | -| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `tools/perf_sample.ps1 -DurationSeconds 300` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO | -| AI 对话 | 连续发送 20 轮短消息 | `tools/perf_sample.ps1 -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 | -| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `tools/perf_sample.ps1 -DurationSeconds 300` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 | -| 天气查询 | 连续查询当前/明天/未来三天天气 | `tools/perf_sample.ps1 -DurationSeconds 300` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 | -| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `tools/perf_sample.ps1 -DurationSeconds 300` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 | -| 应用启动 | 登记一个测试 exe,聊天触发启动并取消/确认各一次 | 手工观察 + 采样 | 启动前始终二次确认;取消时不启动;确认后 UI 可继续响应;不执行脚本或聊天参数 | TODO | TODO | 使用无副作用测试程序,例如记事本或临时测试 exe | -| 本地文件操作 | 读取文本、列目录、复制、备份、重命名 | 手工观察 + 采样 | 写操作有确认;不覆盖、不删除、不访问系统目录;操作后 UI 可继续响应 | TODO | TODO | 使用临时测试目录,不操作真实重要文件 | -| 本地工作区 Agent v1b.5 | 选择临时项目工作区,扫描、搜索文件、搜索文本、读取文件、总结文件、打开普通文件、显示所在位置,并使用独立 Agent 窗口、右键/托盘入口和 `Alt+A` 快捷键操作选中结果 | 手工观察 + `tools/perf_sample.ps1 -DurationSeconds 600` | 扫描和搜索结果数量受限制;独立窗口刷新不阻塞 UI;快捷键注册失败有提示且菜单入口可用;打开文件前二次确认;系统目录、符号链接、危险后缀、敏感文件和命令执行均拒绝;日志不记录敏感文件内容;内存无持续异常增长 | TODO | TODO | 不做创建、修改、移动、删除;使用临时测试仓库,不操作真实重要项目 | +| 重复切换状态 | 通过右键状态测试切换 `idle/think/talk/error/drag` | `perf_sample.ps1 -Scenario state-switch` | 首次切换状态允许加载资源,缓存达到上限后按 LRU 卸载非保护状态 | TODO | TODO | 结合日志确认加载/卸载记录 | +| 动画预热与卸载 | 默认配置启动后静置,随后隐藏到托盘再显示 | `perf_sample.ps1 -Scenario prewarm-unload -DurationSeconds 600` | 日志出现有限次分批预热;隐藏后非保护动画缓存释放;显示后不会反复预热刚被卸载的状态 | TODO | TODO | 不应影响当前播放状态恢复 | +| 缩放 / 置顶切换 | 设置页切换缩放,右键切换置顶 | `perf_sample.ps1 -Scenario scale-topmost` | 窗口尺寸和状态稳定,无崩溃 | TODO | TODO | TODO | +| AI 对话 | 连续发送 20 轮短消息 | `perf_sample.ps1 -Scenario ai-20-turns -DurationSeconds 900` | 请求期间 UI 不阻塞,内存不持续上涨;日志不包含完整消息正文、API Key 或完整错误响应正文 | TODO | TODO | 使用错误 Key/Base URL 时检查脱敏摘要 | +| 提醒触发 | 创建 2 条同时间提醒并等待触发 | `perf_sample.ps1 -Scenario reminder-trigger` | 可见时队列逐条显示;隐藏或 AI 忙时只发系统通知;内存和句柄无异常增长 | TODO | TODO | 检查音效播放和 5 分钟稍后提醒 | +| 天气查询 | 连续查询当前/明天/未来三天天气 | `perf_sample.ps1 -Scenario weather-queries` | 网络请求异步执行,UI 不阻塞;失败时明确提示;日志不记录完整用户原文 | TODO | TODO | 覆盖默认城市、IP fallback、多候选提示 | +| 联网模式 | 使用支持联网的 OpenAI/Gemini Provider 连续发起 5 次联网对话,并覆盖 DeepSeek/Custom 不支持提示 | `perf_sample.ps1 -Scenario web-queries` | 请求异步执行,来源展示不阻塞 UI;不支持 Provider 不发起伪联网;日志不记录完整用户问题或 API Key | TODO | TODO | 输入框打开联网开关;不再测试旧多搜索源/SearXNG 路径 | | 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 | | 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 | @@ -81,7 +87,7 @@ reports/perf/ ```text 1. 后台线程预热 2. 单帧级缓存 -3. 自动化长期压测记录 +3. 经 Release 基线校准的自动性能阈值 ``` ## 发布包说明 @@ -91,6 +97,7 @@ reports/perf/ ```text tools/ docs/ +internal/ reports/ build/ dist/ diff --git a/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md b/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md new file mode 100644 index 0000000..4119177 --- /dev/null +++ b/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md @@ -0,0 +1,66 @@ +# QtDesktopPet 预发布测试记录 + +## 构建信息 + +| 项目 | 内容 | +| --- | --- | +| 测试日期 | 2026-07-17 | +| 版本 / 发布阶段 | 当前开发版 / 预发布回归进行中 | +| 测试源码基线 | `d0d1de4-dirty`;包含已确认的产品能力收缩、无用代码清理和内部 QA 基础设施 | +| 工作树说明 | 用户手测后只新增测试记录,未再修改产品业务代码 | +| 构建类型 | 用户未反馈,待补充 | +| 编译器 / Qt | 待补充 | +| 操作系统 | Windows,具体版本待补充 | +| 显示器 / DPI | 待补充 | +| 测试人 | 用户手动测试 | + +## 自动化摘要 + +| 检查 | 结果 | 摘要 | +| --- | --- | --- | +| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;尚未手动构建内部 QA 工程 | +| 静态检查 | PASS | 13 项通过,0 项失败 | +| 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 | +| GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA | + +## 已执行手测 + +| 用例 | 结果 | 实际结果 / 备注 | +| --- | --- | --- | +| 启动 | PASS | 用户确认通过 | +| 拖动 | PASS | 用户确认通过 | +| 置顶及重启保持 | PASS | 用户确认通过 | +| 托盘隐藏 / 显示 | PASS | 用户确认通过 | +| 单实例唤醒 | PASS | 用户确认通过 | +| 设置页居中 / 置前 | PASS | 用户确认通过 | +| 应用设置保存及重启保持 | PASS | 用户确认通过 | + +## 批次状态 + +| 批次 | 范围 | 结果 | 备注 | +| --- | --- | --- | --- | +| T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 | +| T02 | 应用设置与开机自启动 | IN_PROGRESS | 应用设置持久化通过;开机自启动尚未测试 | +| T03 | 角色管理 | NOT_RUN | | +| T04 | AI 与聊天历史 | NOT_RUN | | +| T05 | 定时提醒 | NOT_RUN | | +| T06 | 天气查询 | NOT_RUN | | +| T07 | 联网模式 | NOT_RUN | | +| T08 | 已移除能力与安全边界 | NOT_RUN | | +| T09 | 配置损坏与隐私 | NOT_RUN | | +| T10 | 打包、安装与卸载 | NOT_RUN | | + +## 性能状态 + +实际 QtDesktopPet 性能场景尚未执行。QA 脚本自检结果不得作为产品性能结论。 + +## 缺陷与复测 + +当前已执行项目未发现缺陷。 + +## 当前结论 + +- 已执行手测:7 项 `PASS`。 +- 已执行静态检查:13 项 `PASS`。 +- 未执行项目仍较多,本记录状态为 `IN_PROGRESS`。 +- 当前不能据此判定可发布。 diff --git a/docs/test-records/2026-07-17_qa-infrastructure_d0d1de4-dirty.md b/docs/test-records/2026-07-17_qa-infrastructure_d0d1de4-dirty.md new file mode 100644 index 0000000..adbcc74 --- /dev/null +++ b/docs/test-records/2026-07-17_qa-infrastructure_d0d1de4-dirty.md @@ -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` diff --git a/docs/test-records/README.md b/docs/test-records/README.md new file mode 100644 index 0000000..5c602da --- /dev/null +++ b/docs/test-records/README.md @@ -0,0 +1,27 @@ +# 测试记录维护规则 + +本目录保存 QtDesktopPet 每次正式回归、候选发布和性能验收的长期记录。验收标准定义在 +`../QtDesktopPet_测试清单与验收标准.md`,本目录只记录某一次构建的实际执行结果。 + +## 文件规则 + +- 每次验收创建独立文件:`YYYY-MM-DD__.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` 登记结论;存在阻断问题时不得标记为可发布。 diff --git a/docs/test-records/TEMPLATE.md b/docs/test-records/TEMPLATE.md new file mode 100644 index 0000000..77a09e5 --- /dev/null +++ b/docs/test-records/TEMPLATE.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 +- 是否允许发布:否 / 是 diff --git a/docs/test-records/index.md b/docs/test-records/index.md new file mode 100644 index 0000000..f032e7d --- /dev/null +++ b/docs/test-records/index.md @@ -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`。 diff --git a/internal/qa/CMakeLists.txt b/internal/qa/CMakeLists.txt new file mode 100644 index 0000000..8847a6e --- /dev/null +++ b/internal/qa/CMakeLists.txt @@ -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) diff --git a/internal/qa/README.md b/internal/qa/README.md new file mode 100644 index 0000000..de2f3ea --- /dev/null +++ b/internal/qa/README.md @@ -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/.csv ` + -Scenario idle-5m +``` + +Raw CSV files stay under `reports/perf/` and are not committed. Reviewed, +sanitized summaries belong in `docs/test-records/`. diff --git a/internal/qa/config/performance-thresholds.json b/internal/qa/config/performance-thresholds.json new file mode 100644 index 0000000..7aa034c --- /dev/null +++ b/internal/qa/config/performance-thresholds.json @@ -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": {} + } +} diff --git a/internal/qa/config/static-checks.json b/internal/qa/config/static-checks.json new file mode 100644 index 0000000..115495d --- /dev/null +++ b/internal/qa/config/static-checks.json @@ -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" + ] +} diff --git a/internal/qa/fixtures/performance/sample-stable.csv b/internal/qa/fixtures/performance/sample-stable.csv new file mode 100644 index 0000000..8c2f4f2 --- /dev/null +++ b/internal/qa/fixtures/performance/sample-stable.csv @@ -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" diff --git a/internal/qa/include/TestHarness.h b/internal/qa/include/TestHarness.h new file mode 100644 index 0000000..fca52f5 --- /dev/null +++ b/internal/qa/include/TestHarness.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace Qa +{ +struct TestCase +{ + std::string name; + std::function function; +}; + +inline std::vector ®istry() +{ + static std::vector tests; + return tests; +} + +class Registrar +{ +public: + Registrar(const char *name, std::function 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) diff --git a/internal/qa/scripts/Invoke-StaticChecks.ps1 b/internal/qa/scripts/Invoke-StaticChecks.ps1 new file mode 100644 index 0000000..605a2a0 --- /dev/null +++ b/internal/qa/scripts/Invoke-StaticChecks.ps1 @@ -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 +} diff --git a/internal/qa/scripts/New-PerformanceReport.ps1 b/internal/qa/scripts/New-PerformanceReport.ps1 new file mode 100644 index 0000000..9680c9f --- /dev/null +++ b/internal/qa/scripts/New-PerformanceReport.ps1 @@ -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 +} diff --git a/tools/perf_sample.ps1 b/internal/qa/scripts/perf_sample.ps1 similarity index 50% rename from tools/perf_sample.ps1 rename to internal/qa/scripts/perf_sample.ps1 index e90381e..a6f53af 100644 --- a/tools/perf_sample.ps1 +++ b/internal/qa/scripts/perf_sample.ps1 @@ -6,6 +6,7 @@ param( [int]$IntervalSeconds = 5, [ValidateRange(1, 604800)] [int]$DurationSeconds = 300, + [string]$Scenario = "unspecified", [string]$OutputPath = "reports/perf" ) @@ -35,39 +36,37 @@ function Resolve-TargetProcess { } function Read-ProcessSnapshot { - param( - [System.Diagnostics.Process]$Process - ) + param([System.Diagnostics.Process]$Process) $path = "" - try { - $path = $Process.Path - } - catch { - $path = "" - } + try { $path = $Process.Path } catch { $path = "" } $responding = "" - try { - $responding = $Process.Responding - } - catch { - $responding = "" - } + try { $responding = $Process.Responding } catch { $responding = "" } return [pscustomobject]@{ - TimestampUtc = (Get-Date).ToUniversalTime().ToString("o") - Pid = $Process.Id - CpuSeconds = [double]($Process.CPU) - WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2) - PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2) - HandleCount = $Process.HandleCount - ThreadCount = $Process.Threads.Count - Responding = $responding - Path = $path + TimestampUtc = (Get-Date).ToUniversalTime().ToString("o") + Pid = $Process.Id + CpuSeconds = [double]($Process.CPU) + WorkingSetMB = [math]::Round($Process.WorkingSet64 / 1MB, 2) + PrivateMemoryMB = [math]::Round($Process.PrivateMemorySize64 / 1MB, 2) + HandleCount = $Process.HandleCount + ThreadCount = $Process.Threads.Count + Responding = $responding + Path = $path } } +function Resolve-OutputDirectory { + param([string]$Path) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\..")) + return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) +} + function New-OutputFilePath { param( [string]$Directory, @@ -87,37 +86,35 @@ $target = Resolve-TargetProcess -Name $ProcessName -Id $ProcessId if ($null -eq $target) { if ($ProcessId -gt 0) { Write-Error "Process with PID $ProcessId was not found." - } - else { + } else { Write-Error "Process '$ProcessName' was not found. Start the app first or pass -Pid." } exit 1 } -$logicalProcessorCount = [Environment]::ProcessorCount -if ($logicalProcessorCount -lt 1) { - $logicalProcessorCount = 1 -} - -$outputFile = New-OutputFilePath -Directory $OutputPath -Name $target.ProcessName +$logicalProcessorCount = [math]::Max(1, [Environment]::ProcessorCount) +$outputDirectory = Resolve-OutputDirectory -Path $OutputPath +$outputFile = New-OutputFilePath -Directory $outputDirectory -Name "$Scenario-$($target.ProcessName)" $sampleCount = [math]::Max(1, [math]::Ceiling($DurationSeconds / $IntervalSeconds)) -Write-Host "Sampling PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds." +Write-Host "Sampling scenario '$Scenario' for PID $($target.Id) ($($target.ProcessName)) every $IntervalSeconds seconds for up to $DurationSeconds seconds." Write-Host "Output: $outputFile" $previousSnapshot = Read-ProcessSnapshot -Process $target $previousTime = Get-Date $firstRow = [pscustomobject]@{ - TimestampUtc = $previousSnapshot.TimestampUtc - Pid = $previousSnapshot.Pid - CpuPercent = 0 - WorkingSetMB = $previousSnapshot.WorkingSetMB - PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB - HandleCount = $previousSnapshot.HandleCount - ThreadCount = $previousSnapshot.ThreadCount - Responding = $previousSnapshot.Responding - Path = $previousSnapshot.Path - Status = "running" + Scenario = $Scenario + SampleIndex = 0 + TimestampUtc = $previousSnapshot.TimestampUtc + Pid = $previousSnapshot.Pid + CpuPercent = 0 + WorkingSetMB = $previousSnapshot.WorkingSetMB + PrivateMemoryMB = $previousSnapshot.PrivateMemoryMB + HandleCount = $previousSnapshot.HandleCount + ThreadCount = $previousSnapshot.ThreadCount + Responding = $previousSnapshot.Responding + Path = $previousSnapshot.Path + Status = "running" } $firstRow | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 @@ -127,16 +124,18 @@ for ($index = 1; $index -le $sampleCount; $index++) { $currentProcess = Get-Process -Id $target.Id -ErrorAction SilentlyContinue if ($null -eq $currentProcess) { [pscustomobject]@{ - TimestampUtc = (Get-Date).ToUniversalTime().ToString("o") - Pid = $target.Id - CpuPercent = "" - WorkingSetMB = "" - PrivateMemoryMB = "" - HandleCount = "" - ThreadCount = "" - Responding = "" - Path = $previousSnapshot.Path - Status = "exited" + Scenario = $Scenario + SampleIndex = $index + TimestampUtc = (Get-Date).ToUniversalTime().ToString("o") + Pid = $target.Id + CpuPercent = "" + WorkingSetMB = "" + PrivateMemoryMB = "" + HandleCount = "" + ThreadCount = "" + Responding = "" + Path = $previousSnapshot.Path + Status = "exited" } | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append Write-Warning "Process $($target.Id) exited. Sampling stopped." @@ -147,25 +146,25 @@ for ($index = 1; $index -le $sampleCount; $index++) { $currentTime = Get-Date $elapsedSeconds = [math]::Max(0.001, ($currentTime - $previousTime).TotalSeconds) $cpuPercent = (($currentSnapshot.CpuSeconds - $previousSnapshot.CpuSeconds) / $elapsedSeconds) * 100 / $logicalProcessorCount - if ($cpuPercent -lt 0) { - $cpuPercent = 0 - } + if ($cpuPercent -lt 0) { $cpuPercent = 0 } [pscustomobject]@{ - TimestampUtc = $currentSnapshot.TimestampUtc - Pid = $currentSnapshot.Pid - CpuPercent = [math]::Round($cpuPercent, 2) - WorkingSetMB = $currentSnapshot.WorkingSetMB - PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB - HandleCount = $currentSnapshot.HandleCount - ThreadCount = $currentSnapshot.ThreadCount - Responding = $currentSnapshot.Responding - Path = $currentSnapshot.Path - Status = "running" + Scenario = $Scenario + SampleIndex = $index + TimestampUtc = $currentSnapshot.TimestampUtc + Pid = $currentSnapshot.Pid + CpuPercent = [math]::Round($cpuPercent, 2) + WorkingSetMB = $currentSnapshot.WorkingSetMB + PrivateMemoryMB = $currentSnapshot.PrivateMemoryMB + HandleCount = $currentSnapshot.HandleCount + ThreadCount = $currentSnapshot.ThreadCount + Responding = $currentSnapshot.Responding + Path = $currentSnapshot.Path + Status = "running" } | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 -Append $previousSnapshot = $currentSnapshot $previousTime = $currentTime } -Write-Host "Sampling finished." +Write-Host "Sampling finished: $outputFile" diff --git a/internal/qa/tests/TestMain.cpp b/internal/qa/tests/TestMain.cpp new file mode 100644 index 0000000..feef44e --- /dev/null +++ b/internal/qa/tests/TestMain.cpp @@ -0,0 +1,44 @@ +#include "TestHarness.h" + +#include +#include +#include + +#include + +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; +} diff --git a/internal/qa/tests/ai/ConversationStoreTests.cpp b/internal/qa/tests/ai/ConversationStoreTests.cpp new file mode 100644 index 0000000..cf5884e --- /dev/null +++ b/internal/qa/tests/ai/ConversationStoreTests.cpp @@ -0,0 +1,118 @@ +#include "TestHarness.h" + +#include "src/ai/ConversationStore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 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 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)); +} diff --git a/internal/qa/tests/assistant/IntentRoutingTests.cpp b/internal/qa/tests/assistant/IntentRoutingTests.cpp new file mode 100644 index 0000000..4e824b2 --- /dev/null +++ b/internal/qa/tests/assistant/IntentRoutingTests.cpp @@ -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); +} diff --git a/internal/qa/tests/reminder/ReminderTests.cpp b/internal/qa/tests/reminder/ReminderTests.cpp new file mode 100644 index 0000000..b23f859 --- /dev/null +++ b/internal/qa/tests/reminder/ReminderTests.cpp @@ -0,0 +1,105 @@ +#include "TestHarness.h" + +#include "src/reminder/ReminderParser.h" +#include "src/reminder/ReminderTypes.h" + +#include +#include +#include +#include + +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 sorted = sortedReminders({later, earlier}); + QA_EXPECT_EQ(sorted.first().id, QStringLiteral("earlier")); + QA_EXPECT_EQ(sorted.last().id, QStringLiteral("later")); +} diff --git a/internal/qa/tests/weather/WeatherTests.cpp b/internal/qa/tests/weather/WeatherTests.cpp new file mode 100644 index 0000000..2048f72 --- /dev/null +++ b/internal/qa/tests/weather/WeatherTests.cpp @@ -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("缺少当前天气"))); +} diff --git a/internal/qa/tests/web/WebCapabilityTests.cpp b/internal/qa/tests/web/WebCapabilityTests.cpp new file mode 100644 index 0000000..5cd7ec1 --- /dev/null +++ b/internal/qa/tests/web/WebCapabilityTests.cpp @@ -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("未配置完整"))); +} diff --git a/src/ai/ConversationManager.cpp b/src/ai/ConversationManager.cpp index 415f765..c615a69 100644 --- a/src/ai/ConversationManager.cpp +++ b/src/ai/ConversationManager.cpp @@ -16,14 +16,7 @@ ConversationManager::ConversationManager() "3. 查询天气:\n" " - 例:“西安天气怎么样”“明天北京天气”“未来三天上海天气”。\n" " - 建议用户使用市级城市名;区县、乡镇、街道不保证精确。\n" - "4. 本地文件操作:\n" - " - 例:“读取文件”“列出文件夹”“复制文件”“备份文件”“重命名文件”。\n" - " - 文件和文件夹必须由用户在系统选择框中主动选择。\n" - " - 不支持删除、覆盖、移动、执行脚本、运行命令或 zip 打包。\n" - "5. 启动本地应用:\n" - " - 例:“打开 Codex”“启动酷狗音乐”“打开 VSCode”。\n" - " - 启动前需要用户确认;不会执行聊天文本里的命令或脚本。\n" - "6. 联网模式:\n" + "4. 联网模式:\n" " - 用户需要打开输入框里的“联网”开关。\n" " - 只有当前 AI 配置支持原生联网时才可用,例如 OpenAI 官方联网能力或 Gemini Google Search grounding。\n" " - DeepSeek 官方 API、第三方 OpenAI-Compatible API 通常不具备可确认的原生联网能力;遇到这类配置时,应明确提醒用户当前配置无法联网,不要假装已经搜索。\n" @@ -31,10 +24,10 @@ ConversationManager::ConversationManager() "回答规则:\n" "- 如果用户只是闲聊,直接自然回复。\n" "- 如果用户问“你能做什么”,用简短列表说明上述功能和示例。\n" - "- 如果用户要做提醒、天气、文件操作或打开应用,可以直接提示用户按示例输入。\n" - "- 如果用户请求危险操作,例如删除文件、覆盖文件、执行命令、运行脚本、提权操作,应明确拒绝,并给出安全替代方案。\n" + "- 如果用户要创建提醒或查询天气,可以直接提示用户按示例输入。\n" + "- 当前应用不能读取、搜索、创建、修改或删除本地文件,不能启动本地应用,也不能执行命令或脚本;遇到这类请求要明确说明能力边界。\n" "- 如果用户询问最新信息但没有开启联网,提醒用户打开“联网”开关;如果当前 AI 配置不支持联网,说明无法使用联网模式。\n" - "- 不要编造已经执行了某个本地操作;只有系统明确完成后,才能说“已完成”。")) + "- 不要声称已经访问本地文件、启动应用或执行系统操作,也不要编造任何未发生的操作结果。")) { } @@ -139,52 +132,6 @@ void ConversationManager::appendExternalExchange(const QString &userMessage, con appendExchange(user, assistant); } -void ConversationManager::sendUserMessage(const QString &message, ResponseCallback callback) -{ - const QString content = message.trimmed(); - if (content.isEmpty()) - { - if (callback) - { - callback({false, {}, QStringLiteral("Message is empty."), 0}); - } - return; - } - - if (!m_provider) - { - if (callback) - { - callback({false, {}, QStringLiteral("AI provider is not ready."), 0}); - } - return; - } - - if (isBusy()) - { - if (callback) - { - callback({false, {}, QStringLiteral("AI request is already running."), 0}); - } - return; - } - - const QDateTime timestamp = QDateTime::currentDateTime(); - const ChatMessage userMessage{QStringLiteral("user"), content, timestamp, m_currentProvider, m_currentModel}; - m_provider->sendChatRequest(buildRequest(userMessage), [this, userMessage, callback = std::move(callback)](const ChatResponse &response) mutable { - if (response.success) - { - ChatMessage assistantMessage{QStringLiteral("assistant"), response.content, QDateTime::currentDateTime(), userMessage.provider, userMessage.model}; - appendExchange(userMessage, assistantMessage); - } - - if (callback) - { - callback(response); - } - }); -} - void ConversationManager::sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback) { const QString content = message.trimmed(); diff --git a/src/ai/ConversationManager.h b/src/ai/ConversationManager.h index f1f0a92..522af5a 100644 --- a/src/ai/ConversationManager.h +++ b/src/ai/ConversationManager.h @@ -27,7 +27,6 @@ public: bool setProvider(std::unique_ptr provider); ChatRequest buildRequestForUserMessage(const QString &message) const; void appendExternalExchange(const QString &userMessage, const QString &assistantMessage); - void sendUserMessage(const QString &message, ResponseCallback callback); void sendUserMessageStreaming(const QString &message, StreamCallback streamCallback, ResponseCallback callback); void cancel(); void clear(); diff --git a/src/assistant/CommandDispatcher.cpp b/src/assistant/CommandDispatcher.cpp index a67022d..f12fe5b 100644 --- a/src/assistant/CommandDispatcher.cpp +++ b/src/assistant/CommandDispatcher.cpp @@ -10,12 +10,6 @@ QString userIntentTypeName(UserIntentType type) return QStringLiteral("Reminder"); case UserIntentType::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"); @@ -24,56 +18,15 @@ QString userIntentTypeName(UserIntentType type) CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const { const UserIntent intent = m_intentRouter.route(text); - if (intent.type == UserIntentType::Chat) + switch (intent.type) { + case UserIntentType::Chat: return {CommandDispatchAction::Chat, intent, intent.text}; - } - - if (intent.type == UserIntentType::Reminder) - { + case UserIntentType::Reminder: return {CommandDispatchAction::Reminder, intent, intent.text}; - } - - if (intent.type == UserIntentType::Weather) - { + case UserIntentType::Weather: return {CommandDispatchAction::Weather, intent, intent.text}; } - if (intent.type == UserIntentType::Workspace) - { - 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("该请求暂时无法处理,请稍后再试。"); + return {CommandDispatchAction::Chat, intent, intent.text}; } diff --git a/src/assistant/CommandDispatcher.h b/src/assistant/CommandDispatcher.h index 184b3ed..07ed5d1 100644 --- a/src/assistant/CommandDispatcher.h +++ b/src/assistant/CommandDispatcher.h @@ -9,10 +9,6 @@ enum class CommandDispatchAction Chat, Reminder, Weather, - Workspace, - FileOperation, - LaunchApp, - UnsupportedTool, }; struct CommandDispatchResult @@ -28,7 +24,5 @@ public: CommandDispatchResult dispatch(const QString &text) const; private: - QString unsupportedToolMessage(UserIntentType type) const; - IntentRouter m_intentRouter; }; diff --git a/src/assistant/IntentRouter.cpp b/src/assistant/IntentRouter.cpp index 84200ea..d4bd386 100644 --- a/src/assistant/IntentRouter.cpp +++ b/src/assistant/IntentRouter.cpp @@ -1,7 +1,6 @@ #include "IntentRouter.h" #include -#include namespace { @@ -14,120 +13,9 @@ bool containsAny(const QString &text, const QStringList &keywords) return true; } } - 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) { static const QStringList keywords = { @@ -140,36 +28,6 @@ bool isReminderIntent(const QString &text) return containsAny(text, keywords); } -bool isFileOperationIntent(const QString &text) -{ - static const QStringList keywords = { - QStringLiteral("文件"), - QStringLiteral("文件夹"), - QStringLiteral("目录"), - QStringLiteral("保存到"), - QStringLiteral("截图"), - QStringLiteral("打包"), - QStringLiteral("压缩"), - QStringLiteral("创建"), - QStringLiteral("新建"), - QStringLiteral("写入"), - QStringLiteral("修改"), - QStringLiteral("修复"), - QStringLiteral("优化"), - QStringLiteral("覆盖"), - QStringLiteral("复制"), - QStringLiteral("备份"), - QStringLiteral("重命名"), - QStringLiteral("删除"), - QStringLiteral("移动"), - QStringLiteral("读取"), - QStringLiteral(".txt"), - QStringLiteral(".md"), - QStringLiteral("日志"), - }; - return containsAny(text, keywords); -} - bool isWeatherIntent(const QString &text) { static const QStringList keywords = { @@ -192,241 +50,18 @@ bool isWeatherIntent(const QString &text) }; 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 { const QString trimmedText = text.trimmed(); - if (isReminderIntent(trimmedText)) { return {UserIntentType::Reminder, trimmedText}; } - if (isWeatherIntent(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}; } diff --git a/src/assistant/UserIntent.h b/src/assistant/UserIntent.h index ee454ab..3c22f7b 100644 --- a/src/assistant/UserIntent.h +++ b/src/assistant/UserIntent.h @@ -7,9 +7,6 @@ enum class UserIntentType Chat, Reminder, Weather, - Workspace, - FileOperation, - LaunchApp, }; struct UserIntent diff --git a/src/character/CharacterPackageRepository.cpp b/src/character/CharacterPackageRepository.cpp index 10b8662..8629678 100644 --- a/src/character/CharacterPackageRepository.cpp +++ b/src/character/CharacterPackageRepository.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace { @@ -535,18 +536,6 @@ QVector CharacterPackageRepository::availablePackages() return packages; } -QStringList CharacterPackageRepository::availablePackageIds() -{ - QStringList packageIds; - const QVector packages = availablePackages(); - for (const CharacterPackageInfo &package : packages) - { - packageIds.append(package.id); - } - - return packageIds; -} - CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath) { CharacterPackageValidationResult result; diff --git a/src/character/CharacterPackageRepository.h b/src/character/CharacterPackageRepository.h index e6c5c87..257d511 100644 --- a/src/character/CharacterPackageRepository.h +++ b/src/character/CharacterPackageRepository.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include struct CharacterPackageInfo @@ -40,7 +39,6 @@ public: static bool hasBuiltInPackage(const QString &characterId); static bool hasUserPackage(const QString &characterId); static QVector availablePackages(); - static QStringList availablePackageIds(); static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath); static bool validatePackageDirectory( const QString &directoryPath, diff --git a/src/character/FrameAnimator.cpp b/src/character/FrameAnimator.cpp index eb68f4f..ae36f3b 100644 --- a/src/character/FrameAnimator.cpp +++ b/src/character/FrameAnimator.cpp @@ -79,11 +79,6 @@ bool FrameAnimator::isPlaying() const return m_timer.isActive() && m_clip != nullptr; } -bool FrameAnimator::isPaused() const -{ - return m_paused && m_clip != nullptr; -} - QString FrameAnimator::currentStateName() const { if (m_clip == nullptr) diff --git a/src/character/FrameAnimator.h b/src/character/FrameAnimator.h index 4caeba9..64e8e08 100644 --- a/src/character/FrameAnimator.h +++ b/src/character/FrameAnimator.h @@ -22,7 +22,6 @@ public: void resume(); void stop(); bool isPlaying() const; - bool isPaused() const; QString currentStateName() const; private: diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index 5090763..4ec1678 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -24,7 +24,4 @@ struct AppConfig int memoryHistoryMessageLimit = 200; bool saveConversationHistory = false; int savedHistoryMessageLimit = 500; - bool agentHotkeyEnabled = true; - QString agentHotkeySequence = QStringLiteral("Alt+A"); - bool agentPanelAutoShow = true; }; diff --git a/src/config/ConfigManager.cpp b/src/config/ConfigManager.cpp index a35c8bc..9aefb04 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -74,15 +74,6 @@ QJsonObject reminderObjectFromConfig(const AppConfig &config) 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) { 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); } - 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; } @@ -418,7 +391,6 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const root.insert(QStringLiteral("chat"), chatObjectFromConfig(config)); root.insert(QStringLiteral("character"), characterObjectFromConfig(config)); root.insert(QStringLiteral("reminder"), reminderObjectFromConfig(config)); - root.insert(QStringLiteral("agent"), agentObjectFromConfig(config)); QFile file(appConfigPath()); 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; } -bool ConfigManager::saveAIConfig(const AIConfig &config) const -{ - AIConfig normalizedConfig = config; - normalizedConfig.provider = normalizedProviderName(normalizedConfig.provider); - if (isRemovedProviderName(normalizedConfig.provider)) - { - normalizedConfig = defaultAIConfigForProvider(QStringLiteral("custom")); - } - - AIConfigStore store = loadAIConfigStore(); - store.activeProvider = normalizedConfig.provider; - store.providers.insert(normalizedConfig.provider, normalizedConfig); - return saveAIConfigStore(store); -} - QString ConfigManager::appConfigPath() const { return QDir(configDirectoryPath()).filePath(AppConfigFileName); diff --git a/src/config/ConfigManager.h b/src/config/ConfigManager.h index 46b9956..961ad96 100644 --- a/src/config/ConfigManager.h +++ b/src/config/ConfigManager.h @@ -15,7 +15,6 @@ public: AIConfig loadAIConfig() const; bool saveAppConfig(const AppConfig &config) const; bool saveAIConfigStore(const AIConfigStore &store) const; - bool saveAIConfig(const AIConfig &config) const; QString appConfigPath() const; QString aiConfigPath() const; QString conversationHistoryPath() const; diff --git a/src/fileops/FileBackupManager.cpp b/src/fileops/FileBackupManager.cpp deleted file mode 100644 index c5a7a48..0000000 --- a/src/fileops/FileBackupManager.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "FileBackupManager.h" - -#include -#include -#include -#include - -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; -} diff --git a/src/fileops/FileBackupManager.h b/src/fileops/FileBackupManager.h deleted file mode 100644 index 244d39c..0000000 --- a/src/fileops/FileBackupManager.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include "FileSandbox.h" - -#include - -class FileBackupManager -{ -public: - bool createBackup(const QString &sourceFilePath, QString *backupPath = nullptr, QString *errorMessage = nullptr) const; - -private: - FileSandbox m_sandbox; -}; diff --git a/src/fileops/FileOperationManager.cpp b/src/fileops/FileOperationManager.cpp deleted file mode 100644 index 66ca3f4..0000000 --- a/src/fileops/FileOperationManager.cpp +++ /dev/null @@ -1,208 +0,0 @@ -#include "FileOperationManager.h" - -#include -#include -#include -#include -#include - -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; -} diff --git a/src/fileops/FileOperationManager.h b/src/fileops/FileOperationManager.h deleted file mode 100644 index 3df8444..0000000 --- a/src/fileops/FileOperationManager.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include "FileBackupManager.h" -#include "FileOperationTypes.h" -#include "FileSandbox.h" - -#include - -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; -}; diff --git a/src/fileops/FileOperationTypes.h b/src/fileops/FileOperationTypes.h deleted file mode 100644 index 2321d58..0000000 --- a/src/fileops/FileOperationTypes.h +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include - -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; -}; diff --git a/src/fileops/FileSandbox.cpp b/src/fileops/FileSandbox.cpp deleted file mode 100644 index b541dc3..0000000 --- a/src/fileops/FileSandbox.cpp +++ /dev/null @@ -1,212 +0,0 @@ -#include "FileSandbox.h" - -#include -#include -#include -#include - -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; -} diff --git a/src/fileops/FileSandbox.h b/src/fileops/FileSandbox.h deleted file mode 100644 index abcea17..0000000 --- a/src/fileops/FileSandbox.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -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; -}; diff --git a/src/launcher/AppDiscovery.cpp b/src/launcher/AppDiscovery.cpp deleted file mode 100644 index fbdd342..0000000 --- a/src/launcher/AppDiscovery.cpp +++ /dev/null @@ -1,194 +0,0 @@ -#include "AppDiscovery.h" - -#include -#include -#include -#include -#include -#include -#include - -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; -} diff --git a/src/launcher/AppDiscovery.h b/src/launcher/AppDiscovery.h deleted file mode 100644 index c5be2c0..0000000 --- a/src/launcher/AppDiscovery.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "AppLaunchTypes.h" - -#include - -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; -}; diff --git a/src/launcher/AppLaunchManager.cpp b/src/launcher/AppLaunchManager.cpp deleted file mode 100644 index 74cd620..0000000 --- a/src/launcher/AppLaunchManager.cpp +++ /dev/null @@ -1,291 +0,0 @@ -#include "AppLaunchManager.h" - -#include "../util/Logger.h" - -#include -#include -#include -#include -#include - -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; -} diff --git a/src/launcher/AppLaunchManager.h b/src/launcher/AppLaunchManager.h deleted file mode 100644 index 38ab808..0000000 --- a/src/launcher/AppLaunchManager.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "AppDiscovery.h" -#include "AppLaunchTypes.h" - -#include - -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; -}; diff --git a/src/launcher/AppLaunchStore.cpp b/src/launcher/AppLaunchStore.cpp deleted file mode 100644 index b32a42a..0000000 --- a/src/launcher/AppLaunchStore.cpp +++ /dev/null @@ -1,302 +0,0 @@ -#include "AppLaunchStore.h" - -#include "../util/Logger.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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 apps; - QSet 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)); -} diff --git a/src/launcher/AppLaunchStore.h b/src/launcher/AppLaunchStore.h deleted file mode 100644 index ea491bd..0000000 --- a/src/launcher/AppLaunchStore.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "AppLaunchTypes.h" - -#include - -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; -}; diff --git a/src/launcher/AppLaunchTypes.h b/src/launcher/AppLaunchTypes.h deleted file mode 100644 index 8ab957f..0000000 --- a/src/launcher/AppLaunchTypes.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include -#include -#include - -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 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; -}; diff --git a/src/system/GlobalHotkeyManager.cpp b/src/system/GlobalHotkeyManager.cpp deleted file mode 100644 index 3b90849..0000000 --- a/src/system/GlobalHotkeyManager.cpp +++ /dev/null @@ -1,209 +0,0 @@ -#include "GlobalHotkeyManager.h" - -#include "../util/Logger.h" - -#include -#include -#include -#include - -#include - -#ifdef Q_OS_WIN -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#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('A' + key - Qt::Key_A); - } - else if (key >= Qt::Key_0 && key <= Qt::Key_9) - { - nativeKey = static_cast('0' + key - Qt::Key_0); - } - else if (key >= Qt::Key_F1 && key <= Qt::Key_F12) - { - nativeKey = static_cast(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(message); - if (msg != nullptr && msg->message == WM_HOTKEY && static_cast(msg->wParam) == HotkeyId) - { - if (m_callback) - { - m_callback(); - } - return true; - } -#else - Q_UNUSED(message); -#endif - - return false; -} diff --git a/src/system/GlobalHotkeyManager.h b/src/system/GlobalHotkeyManager.h deleted file mode 100644 index f6992e7..0000000 --- a/src/system/GlobalHotkeyManager.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -class GlobalHotkeyManager : public QAbstractNativeEventFilter -{ -public: - using ActivatedCallback = std::function; - - 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; -}; diff --git a/src/tray/TrayController.cpp b/src/tray/TrayController.cpp index 4758ff0..6b1d1bc 100644 --- a/src/tray/TrayController.cpp +++ b/src/tray/TrayController.cpp @@ -85,11 +85,6 @@ void TrayController::createMenu() hidePetWindow(); }); - QAction *workspacePanelAction = m_menu.addAction(QStringLiteral("Agent 工作区")); - QObject::connect(workspacePanelAction, &QAction::triggered, [this]() { - showWorkspacePanel(); - }); - QAction *settingsAction = m_menu.addAction(QStringLiteral("设置...")); QObject::connect(settingsAction, &QAction::triggered, [this]() { openSettings(); @@ -142,16 +137,6 @@ void TrayController::togglePetWindowVisibility() showPetWindow(); } -void TrayController::showWorkspacePanel() -{ - if (m_petWindow == nullptr) - { - return; - } - - m_petWindow->showWorkspacePanelFromUserAction(); -} - void TrayController::openSettings() { if (m_petWindow == nullptr) diff --git a/src/tray/TrayController.h b/src/tray/TrayController.h index 4d591d2..4b404e4 100644 --- a/src/tray/TrayController.h +++ b/src/tray/TrayController.h @@ -19,7 +19,6 @@ private: void showPetWindow(); void hidePetWindow(); void togglePetWindowVisibility(); - void showWorkspacePanel(); void openSettings(); void quitApplication(); diff --git a/src/ui/ChatBubble.cpp b/src/ui/ChatBubble.cpp index 3dc13cb..ccd027d 100644 --- a/src/ui/ChatBubble.cpp +++ b/src/ui/ChatBubble.cpp @@ -1,11 +1,11 @@ #include "ChatBubble.h" -#include #include #include #include #include #include +#include #include #include #include @@ -48,7 +48,6 @@ ChatBubble::ChatBubble(QWidget *parent) m_textEdit->installEventFilter(this); m_textEdit->viewport()->installEventFilter(this); m_textEdit->verticalScrollBar()->installEventFilter(this); - qApp->installEventFilter(this); m_textEdit->setStyleSheet(QStringLiteral( "QTextEdit {" "background: rgba(255, 255, 255, 232);" @@ -80,17 +79,8 @@ ChatBubble::ChatBubble(QWidget *parent) }); } -ChatBubble::~ChatBubble() -{ - if (qApp != nullptr) - { - qApp->removeEventFilter(this); - } -} - void ChatBubble::showMessage(const QString &message, const QPoint &anchorPosition, int durationMs, bool scrollToBottom) { - m_dismissOnExternalInteraction = false; m_anchorPosition = anchorPosition; m_autoHideDurationMs = durationMs; const QString trimmed = message.trimmed(); @@ -129,11 +119,6 @@ void ChatBubble::updateAnchorPosition(const QPoint &anchorPosition) } } -void ChatBubble::setDismissOnExternalInteraction(bool enabled) -{ - m_dismissOnExternalInteraction = enabled; -} - void ChatBubble::resetAutoHideTimer() { if (isVisible() && m_autoHideDurationMs > 0) @@ -150,18 +135,6 @@ void ChatBubble::hideBubble() bool ChatBubble::eventFilter(QObject *watched, QEvent *event) { - if (watched == qApp && m_dismissOnExternalInteraction && event->type() == QEvent::ApplicationDeactivate) - { - hideBubble(); - return false; - } - - if (m_dismissOnExternalInteraction && event->type() == QEvent::MouseButtonPress && !isOwnObject(watched)) - { - hideBubble(); - return false; - } - if ((watched == m_textEdit || watched == m_textEdit->viewport() || watched == m_textEdit->verticalScrollBar()) && isUserInteractionEvent(event)) { @@ -171,21 +144,6 @@ bool ChatBubble::eventFilter(QObject *watched, QEvent *event) return QWidget::eventFilter(watched, event); } -bool ChatBubble::isOwnObject(QObject *object) const -{ - while (object != nullptr) - { - if (object == this || object == m_textEdit || object == m_textEdit->viewport() || object == m_textEdit->verticalScrollBar()) - { - return true; - } - - object = object->parent(); - } - - return false; -} - bool ChatBubble::isUserInteractionEvent(QEvent *event) const { switch (event->type()) diff --git a/src/ui/ChatBubble.h b/src/ui/ChatBubble.h index aec245e..be649b6 100644 --- a/src/ui/ChatBubble.h +++ b/src/ui/ChatBubble.h @@ -1,16 +1,15 @@ #pragma once -#include #include #include class QEvent; +class QTextEdit; class ChatBubble : public QWidget { public: explicit ChatBubble(QWidget *parent = nullptr); - ~ChatBubble() override; void showMessage( const QString &message, @@ -18,7 +17,6 @@ public: int durationMs = 10000, bool scrollToBottom = false); void updateAnchorPosition(const QPoint &anchorPosition); - void setDismissOnExternalInteraction(bool enabled); void resetAutoHideTimer(); void hideBubble(); @@ -26,7 +24,6 @@ protected: bool eventFilter(QObject *watched, QEvent *event) override; private: - bool isOwnObject(QObject *object) const; bool isUserInteractionEvent(QEvent *event) const; QSize preferredBubbleSize(const QString &message) const; void updatePosition(); @@ -35,5 +32,4 @@ private: QTimer m_hideTimer; QPoint m_anchorPosition; int m_autoHideDurationMs = 0; - bool m_dismissOnExternalInteraction = false; }; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 48b921c..19c393d 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -7,15 +7,11 @@ #include "../character/CharacterPackageLoader.h" #include "../character/CharacterPackageRepository.h" #include "../config/ConfigManager.h" -#include "../fileops/FileOperationManager.h" -#include "../launcher/AppLaunchManager.h" -#include "../launcher/AppLaunchStore.h" #include "../notification/NotificationDispatcher.h" #include "../reminder/ReminderCommandHandler.h" #include "../reminder/ReminderManager.h" #include "../reminder/ReminderSoundPlayer.h" #include "../reminder/ReminderSoundRepository.h" -#include "../system/GlobalHotkeyManager.h" #include "../system/StartupManager.h" #include "../util/Logger.h" #include "../web/WebCapabilityDetector.h" @@ -23,31 +19,22 @@ #include "../web/WebStore.h" #include "../weather/WeatherManager.h" #include "../weather/WeatherStore.h" -#include "../workspace/WorkspaceAgent.h" -#include "../workspace/WorkspaceController.h" #include "ChatBubble.h" #include "ChatHistoryPanel.h" #include "ChatInputDialog.h" #include "PetView.h" #include "SettingsDialog.h" -#include "WorkspaceAgentWindow.h" #include #include #include #include #include -#include -#include -#include #include #include #include #include -#include -#include #include -#include #include #include #include @@ -55,13 +42,11 @@ #include #include #include -#include #include #include #include #include #include -#include #include #include @@ -122,11 +107,6 @@ AppConfig normalizedAppConfig(AppConfig config) } config.reminderSoundId = ReminderSoundRepository::soundInfo(config.reminderSoundId).id; config.reminderSoundVolume = qBound(0.0, config.reminderSoundVolume, 1.0); - config.agentHotkeySequence = config.agentHotkeySequence.trimmed(); - if (config.agentHotkeySequence.isEmpty()) - { - config.agentHotkeySequence = QStringLiteral("Alt+A"); - } return config; } @@ -202,12 +182,6 @@ QString webCitationListText(const QVector &citations) return lines.join(QStringLiteral("\n")); } -bool isWorkspaceSelectionMessage(const QString &message) -{ - return message.contains(QStringLiteral("打开工作区"), Qt::CaseInsensitive) - || message.contains(QStringLiteral("选择工作区"), Qt::CaseInsensitive); -} - } PetWindow::PetWindow(QWidget *parent) @@ -217,11 +191,6 @@ PetWindow::PetWindow(QWidget *parent) , m_chatInputDialog(std::make_unique(MaxUserMessageLength, this)) , m_conversationManager(std::make_unique()) , m_conversationStore(std::make_unique(ConfigManager().conversationHistoryPath())) - , m_fileOperationManager(std::make_unique()) - , m_workspaceController(std::make_unique()) - , m_workspaceAgentWindow(std::make_unique()) - , m_globalHotkeyManager(std::make_unique()) - , m_appLaunchManager(std::make_unique()) , m_notificationDispatcher(std::make_unique()) , m_reminderManager(std::make_unique()) , m_reminderSoundPlayer(std::make_unique()) @@ -280,46 +249,6 @@ PetWindow::PetWindow(QWidget *parent) window->saveWebTogglePreference(webEnabled); } }); - m_workspaceAgentWindow->setMessageCallback([window](const QString &message) { - return !window.isNull() && window->submitWorkspaceAgentWindowMessage(message); - }); - m_workspaceAgentWindow->setChooseWorkspaceCallback([window]() { - if (!window.isNull()) - { - window->submitWorkspaceAgentWindowMessage(QStringLiteral("选择工作区")); - } - }); - m_workspaceAgentWindow->setRescanCallback([window]() { - if (!window.isNull()) - { - window->submitWorkspaceAgentWindowMessage(QStringLiteral("重新扫描工作区")); - } - }); - m_workspaceAgentWindow->setReadCallback([window](int resultIndex) { - if (!window.isNull()) - { - window->handleWorkspacePanelRead(resultIndex); - } - }); - m_workspaceAgentWindow->setOpenCallback([window](int resultIndex) { - if (!window.isNull()) - { - window->handleWorkspacePanelOpen(resultIndex); - } - }); - m_workspaceAgentWindow->setRevealCallback([window](int resultIndex) { - if (!window.isNull()) - { - window->handleWorkspacePanelReveal(resultIndex); - } - }); - m_globalHotkeyManager->setActivatedCallback([window]() { - if (!window.isNull()) - { - window->showWorkspacePanelFromUserAction(); - } - }); - m_reminderManager->setTriggeredCallback([window](const ReminderItem &item) { if (!window.isNull()) { @@ -351,7 +280,6 @@ void PetWindow::applyAppConfig(const AppConfig &config) m_appConfig = normalizedConfig; setAlwaysOnTop(m_appConfig.alwaysOnTop); - configureAgentHotkey(); configureConversation(loadPersistedHistory); if (m_appConfig.hasWindowPosition && isPointVisibleOnScreen(m_appConfig.windowPosition)) @@ -445,39 +373,11 @@ void PetWindow::showBubbleMessage(const QString &message) m_chatBubble->showMessage(message, bubbleAnchorPosition()); } -void PetWindow::showWorkspacePanelFromUserAction() -{ - showWorkspaceAgentWindowFromUserAction(); -} - -void PetWindow::showWorkspaceAgentWindowFromUserAction() -{ - if (m_workspaceAgentWindow == nullptr) - { - return; - } - - refreshWorkspacePanel(); - m_workspaceAgentWindow->showWindow(); -} - -bool PetWindow::submitWorkspaceAgentWindowMessage(const QString &message) -{ - const QString trimmedMessage = message.trimmed(); - if (trimmedMessage.isEmpty()) - { - return false; - } - - return handleWorkspaceChatMessage(trimmedMessage); -} - void PetWindow::openSettingsDialog() { ConfigManager configManager; WeatherStore weatherStore; WebStore webStore; - AppLaunchStore appLaunchStore; QString weatherConfigError; const WeatherConfig weatherConfig = weatherStore.load(&weatherConfigError); if (!weatherConfigError.isEmpty()) @@ -490,19 +390,11 @@ void PetWindow::openSettingsDialog() { Logger::warning(QStringLiteral("Web config load warning: ") + webConfigError); } - QString appLaunchConfigError; - const AppLaunchConfig appLaunchConfig = appLaunchStore.load(&appLaunchConfigError); - if (!appLaunchConfigError.isEmpty()) - { - Logger::warning(QStringLiteral("Launcher config load warning: ") + appLaunchConfigError); - } - SettingsDialog dialog( configManager.loadAIConfigStore(), currentAppConfig(), weatherConfig, webConfig, - appLaunchConfig, m_conversationManager ? m_conversationManager->history() : QVector(), m_reminderManager ? m_reminderManager->allReminders() : QVector(), [this]() { @@ -605,11 +497,6 @@ void PetWindow::openSettingsDialog() } refreshChatInputWebToggle(); - QString saveLauncherConfigError; - if (!appLaunchStore.save(dialog.appLaunchConfig(), &saveLauncherConfigError)) - { - Logger::warning(QStringLiteral("Failed to save launcher config from settings dialog: ") + saveLauncherConfigError); - } } void PetWindow::activateFromExternalInstance() @@ -668,7 +555,6 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) QAction *chatAction = menu.addAction(QStringLiteral("聊天")); chatAction->setEnabled(!aiRequestRunning); QAction *showConversationAction = menu.addAction(QStringLiteral("显示对话")); - QAction *workspacePanelAction = menu.addAction(QStringLiteral("Agent 工作区")); QAction *cancelAIAction = menu.addAction(QStringLiteral("取消当前请求")); cancelAIAction->setEnabled(aiRequestRunning); QAction *clearConversationAction = menu.addAction(QStringLiteral("清空对话")); @@ -697,10 +583,6 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) { showConversationHistory(); } - else if (selectedAction == workspacePanelAction) - { - showWorkspacePanelFromUserAction(); - } else if (selectedAction == cancelAIAction) { cancelActiveAIRequest(); @@ -766,28 +648,6 @@ bool PetWindow::submitChatMessage(const QString &message, bool webEnabled) return handleWeatherChatMessage(result.message); } - if (result.action == CommandDispatchAction::Workspace) - { - return handleWorkspaceChatMessage(result.message); - } - - if (result.action == CommandDispatchAction::FileOperation) - { - return handleFileOperationChatMessage(result.message); - } - - if (result.action == CommandDispatchAction::LaunchApp) - { - return handleLaunchAppChatMessage(result.message); - } - - if (result.action == CommandDispatchAction::UnsupportedTool) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(result.message); - return true; - } - saveWebTogglePreference(webEnabled); return webEnabled ? submitWebChatMessage(result.message) : submitAiChatMessage(result.message); } @@ -809,186 +669,6 @@ bool PetWindow::handleReminderChatMessage(const QString &message) return result.success; } -bool PetWindow::handleWorkspaceChatMessage(const QString &message) -{ - if (!m_workspaceController) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(QStringLiteral("本地工作区功能初始化失败。")); - return false; - } - - const QString text = message.trimmed(); - appendWorkspaceUserTask(text); - if (isWorkspaceSelectionMessage(text)) - { - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("选择工作区")); - } - QWidget *dialogParent = (m_workspaceAgentWindow && m_workspaceAgentWindow->isVisible()) - ? static_cast(m_workspaceAgentWindow.get()) - : static_cast(this); - const QString workspacePath = QFileDialog::getExistingDirectory( - dialogParent, - QStringLiteral("选择工作区目录"), - QString(), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (workspacePath.isEmpty()) - { - return false; - } - - stopAnimationPrewarm(); - playState(QStringLiteral("think"), false); - showBubbleMessage(QStringLiteral("正在扫描工作区...")); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("正在扫描")); - } - - QString errorMessage; - if (!m_workspaceController->openWorkspace(workspacePath, &errorMessage)) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(errorMessage.isEmpty() ? QStringLiteral("打开工作区失败。") : errorMessage); - appendWorkspaceAgentMessage(QStringLiteral("打开工作区失败"), errorMessage.isEmpty() ? QStringLiteral("打开工作区失败。") : errorMessage, true); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("失败")); - } - return false; - } - - const WorkspaceToolResult info = m_workspaceController->handleMessage(QStringLiteral("当前工作区")); - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("已打开工作区。")); - if (!info.outputText.trimmed().isEmpty()) - { - appendWorkspaceAgentMessage(QStringLiteral("当前工作区"), info.outputText); - } - refreshWorkspacePanel(); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("就绪")); - } - if (m_appConfig.agentPanelAutoShow) - { - showWorkspacePanel(); - } - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - return true; - } - - if (hasActiveWorkspaceAIRequest()) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("工作区分析正在进行,请稍后。")); - appendWorkspaceAgentMessage(QStringLiteral("工作区分析进行中"), QStringLiteral("工作区分析正在进行,请稍后。"), true); - return false; - } - - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("处理中")); - } - return consumeWorkspaceToolResult(m_workspaceController->handleMessage(text)); -} - -bool PetWindow::consumeWorkspaceToolResult(const WorkspaceToolResult &result) -{ - if (!result.success) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(result.errorMessage.isEmpty() ? QStringLiteral("工作区请求失败。") : result.errorMessage); - appendWorkspaceAgentMessage(QStringLiteral("工作区请求失败"), result.errorMessage.isEmpty() ? QStringLiteral("工作区请求失败。") : result.errorMessage, true); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("失败")); - } - refreshWorkspacePanel(); - return false; - } - - refreshWorkspacePanel(); - if (result.hasActionPlan) - { - const WorkspaceActionPlan plan = result.actionPlan; - if (plan.requiresConfirmation) - { - QString confirmText = plan.description; - if (!plan.warnings.isEmpty()) - { - confirmText += QStringLiteral("\n\n注意:\n") + plan.warnings.join(QLatin1Char('\n')); - } - confirmText += QStringLiteral("\n\n请确认是否执行。"); - QWidget *dialogParent = (m_workspaceAgentWindow && m_workspaceAgentWindow->isVisible()) - ? static_cast(m_workspaceAgentWindow.get()) - : static_cast(this); - const QMessageBox::StandardButton selectedButton = QMessageBox::question( - dialogParent, - plan.title.isEmpty() ? QStringLiteral("确认工作区操作") : plan.title, - confirmText, - QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel); - if (selectedButton != QMessageBox::Yes) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("已取消工作区操作。")); - appendWorkspaceAgentMessage(QStringLiteral("工作区操作已取消"), QStringLiteral("用户取消了本次操作。")); - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - refreshWorkspacePanel(); - return false; - } - } - - const WorkspaceToolResult actionResult = m_workspaceController->executeActionPlan(plan); - refreshWorkspacePanel(); - playState(actionResult.success ? QStringLiteral("talk") : QStringLiteral("error"), false); - showBubbleMessage(actionResult.success - ? (actionResult.message.isEmpty() ? QStringLiteral("工作区操作已完成。") : actionResult.message) - : (actionResult.errorMessage.isEmpty() ? QStringLiteral("工作区操作失败。") : actionResult.errorMessage)); - appendWorkspaceAgentMessage( - actionResult.success ? QStringLiteral("工作区操作完成") : QStringLiteral("工作区操作失败"), - actionResult.success - ? (actionResult.outputText.trimmed().isEmpty() ? actionResult.message : actionResult.outputText) - : actionResult.errorMessage, - !actionResult.success); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(actionResult.success ? QStringLiteral("就绪") : QStringLiteral("失败")); - } - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - return actionResult.success; - } - - playState(QStringLiteral("talk"), false); - showBubbleMessage(result.message.isEmpty() ? QStringLiteral("工作区请求已完成。") : result.message); - if (!result.outputText.trimmed().isEmpty()) - { - appendWorkspaceAgentMessage( - result.message.isEmpty() ? QStringLiteral("本地工作区") : result.message, - result.outputText); - } - if (m_appConfig.agentPanelAutoShow) - { - showWorkspacePanel(); - } - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(result.shouldRequestAiSummary ? QStringLiteral("等待 AI 分析") : QStringLiteral("就绪")); - } - - if (result.shouldRequestAiSummary) - { - requestWorkspaceAiSummary(result); - } - else - { - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - } - return true; -} bool PetWindow::handleWeatherChatMessage(const QString &message) { @@ -1048,512 +728,7 @@ bool PetWindow::handleWeatherChatMessage(const QString &message) return true; } -bool PetWindow::handleFileOperationChatMessage(const QString &message) -{ - if (!m_fileOperationManager) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(QStringLiteral("文件操作功能初始化失败。")); - return false; - } - const QString text = message.trimmed(); - const auto contains = [&text](const QString &keyword) { - return text.contains(keyword, Qt::CaseInsensitive); - }; - if (contains(QStringLiteral("创建")) || contains(QStringLiteral("新建")) - || contains(QStringLiteral("写入")) || contains(QStringLiteral("修改")) - || contains(QStringLiteral("修复")) || contains(QStringLiteral("优化"))) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(QStringLiteral("文件操作 v1 不支持创建、新建、写入、修改、修复或优化文件。")); - return false; - } - - if (contains(QStringLiteral("删除")) || contains(QStringLiteral("移动")) || contains(QStringLiteral("覆盖")) - || contains(QStringLiteral("执行")) || contains(QStringLiteral("运行")) || contains(QStringLiteral("脚本")) || contains(QStringLiteral("命令"))) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(QStringLiteral("文件操作 v1 不支持删除、覆盖、移动、执行脚本或运行命令。")); - return false; - } - - if (contains(QStringLiteral("截图")) || contains(QStringLiteral("保存到"))) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("文件操作 v1 暂不支持截图或把当前内容保存到指定位置。")); - return false; - } - - if (contains(QStringLiteral("打包")) || contains(QStringLiteral("压缩")) || contains(QStringLiteral("zip"))) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("zip 打包需要额外稳定的压缩实现,本版暂不启用;可以先使用复制或备份。")); - return false; - } - - const auto confirmPlan = [this](const FileOperationPlan &plan) { - QString messageText = plan.description; - if (!plan.warnings.isEmpty()) - { - messageText += QStringLiteral("\n\n注意:\n") + plan.warnings.join(QLatin1Char('\n')); - } - messageText += QStringLiteral("\n\n请确认是否执行。"); - return QMessageBox::warning( - this, - plan.title, - messageText, - QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel) == QMessageBox::Yes; - }; - - FileOperationResult operationResult; - if (contains(QStringLiteral("列出")) || contains(QStringLiteral("目录")) || contains(QStringLiteral("文件夹"))) - { - const QString directoryPath = QFileDialog::getExistingDirectory( - this, - QStringLiteral("选择要列出的文件夹"), - QString(), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (directoryPath.isEmpty()) - { - return false; - } - - operationResult = m_fileOperationManager->executeListDirectory(m_fileOperationManager->listDirectoryPlan(directoryPath)); - } - else if (contains(QStringLiteral("复制"))) - { - const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要复制的文件")); - if (sourceFilePath.isEmpty()) - { - return false; - } - const QString targetDirectoryPath = QFileDialog::getExistingDirectory( - this, - QStringLiteral("选择复制到的文件夹"), - QString(), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (targetDirectoryPath.isEmpty()) - { - return false; - } - - const FileOperationPlan plan = m_fileOperationManager->copyFilePlan(sourceFilePath, targetDirectoryPath); - if (!confirmPlan(plan)) - { - return false; - } - operationResult = m_fileOperationManager->executeCopyFile(plan); - } - else if (contains(QStringLiteral("备份"))) - { - const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要备份的文件")); - if (sourceFilePath.isEmpty()) - { - return false; - } - - const FileOperationPlan plan = m_fileOperationManager->backupFilePlan(sourceFilePath); - if (!confirmPlan(plan)) - { - return false; - } - operationResult = m_fileOperationManager->executeBackupFile(plan); - } - else if (contains(QStringLiteral("重命名"))) - { - const QString sourceFilePath = QFileDialog::getOpenFileName(this, QStringLiteral("选择要重命名的文件")); - if (sourceFilePath.isEmpty()) - { - return false; - } - - bool ok = false; - const QString newFileName = QInputDialog::getText( - this, - QStringLiteral("重命名文件"), - QStringLiteral("新文件名"), - QLineEdit::Normal, - QFileInfo(sourceFilePath).fileName(), - &ok).trimmed(); - if (!ok || newFileName.isEmpty()) - { - return false; - } - - const FileOperationPlan plan = m_fileOperationManager->renameFilePlan(sourceFilePath, newFileName); - if (!confirmPlan(plan)) - { - return false; - } - operationResult = m_fileOperationManager->executeRenameFile(plan); - } - else - { - const QString sourceFilePath = QFileDialog::getOpenFileName( - this, - QStringLiteral("选择要读取的文本文件"), - QString(), - QStringLiteral("Text Files (*.txt *.md *.markdown *.log *.json *.csv *.ini *.xml *.yaml *.yml);;All Files (*)")); - if (sourceFilePath.isEmpty()) - { - return false; - } - - operationResult = m_fileOperationManager->executeReadTextFile(m_fileOperationManager->readTextFilePlan(sourceFilePath)); - } - - if (!operationResult.success) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(operationResult.errorMessage.isEmpty() ? QStringLiteral("文件操作失败。") : operationResult.errorMessage); - return false; - } - - playState(QStringLiteral("talk"), false); - const QString output = operationResult.outputText.trimmed(); - showBubbleMessage(output.isEmpty() ? operationResult.message : output); - return true; -} - -bool PetWindow::handleLaunchAppChatMessage(const QString &message) -{ - if (!m_appLaunchManager) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(QStringLiteral("应用启动功能初始化失败。")); - return false; - } - - AppLaunchStore store; - QString loadError; - AppLaunchConfig config = store.load(&loadError); - if (!loadError.isEmpty()) - { - Logger::warning(QStringLiteral("Launcher config load warning: ") + loadError); - } - - AppLaunchPlan plan = m_appLaunchManager->resolveLaunchPlan(message, config); - if (plan.needsManualSelection) - { - const QString executablePath = QFileDialog::getOpenFileName( - this, - QStringLiteral("选择要启动的应用"), - QString(), - QStringLiteral("Applications (*.exe)")); - if (executablePath.isEmpty()) - { - return false; - } - - plan = m_appLaunchManager->manualSelectionPlan(message, executablePath); - } - - if (!plan.success) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(plan.errorMessage.isEmpty() ? QStringLiteral("没有找到可启动的应用。") : plan.errorMessage); - return false; - } - - QString confirmText = QStringLiteral("应用:%1\n来源:%2") - .arg(plan.displayName.isEmpty() ? plan.requestedName : plan.displayName, - plan.matchSource.isEmpty() ? QStringLiteral("未知") : plan.matchSource); - if (!plan.executablePath.trimmed().isEmpty()) - { - confirmText += QStringLiteral("\n路径:") + plan.executablePath; - } - if (!plan.shortcutPath.trimmed().isEmpty()) - { - confirmText += QStringLiteral("\n快捷方式:") + plan.shortcutPath; - } - if (!plan.workingDirectory.trimmed().isEmpty()) - { - confirmText += QStringLiteral("\n工作目录:") + plan.workingDirectory; - } - QDialog confirmDialog(this); - confirmDialog.setWindowTitle(QStringLiteral("启动应用")); - confirmDialog.setModal(true); - - auto *confirmLayout = new QVBoxLayout(&confirmDialog); - confirmLayout->setContentsMargins(18, 18, 18, 14); - confirmLayout->setSpacing(12); - - auto *questionLabel = new QLabel(QStringLiteral("确认启动该应用?"), &confirmDialog); - QFont questionFont = questionLabel->font(); - questionFont.setBold(true); - questionLabel->setFont(questionFont); - confirmLayout->addWidget(questionLabel); - - auto *detailLabel = new QLabel(confirmText, &confirmDialog); - detailLabel->setWordWrap(true); - detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - detailLabel->setMinimumWidth(520); - confirmLayout->addWidget(detailLabel); - - auto *rememberCheckBox = new QCheckBox(QStringLiteral("记住为此名称,下次直接匹配"), &confirmDialog); - rememberCheckBox->setVisible(plan.canRemember); - confirmLayout->addWidget(rememberCheckBox); - - auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &confirmDialog); - if (QPushButton *okButton = buttonBox->button(QDialogButtonBox::Ok)) - { - okButton->setText(QStringLiteral("启动")); - } - if (QPushButton *cancelButton = buttonBox->button(QDialogButtonBox::Cancel)) - { - cancelButton->setText(QStringLiteral("取消")); - } - QObject::connect(buttonBox, &QDialogButtonBox::accepted, &confirmDialog, &QDialog::accept); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, &confirmDialog, &QDialog::reject); - confirmLayout->addWidget(buttonBox); - - if (confirmDialog.exec() != QDialog::Accepted) - { - return false; - } - - const AppLaunchResult result = m_appLaunchManager->executeLaunchPlan(plan); - if (!result.success) - { - playState(QStringLiteral("error"), false); - showBubbleMessage(result.errorMessage.isEmpty() ? QStringLiteral("启动应用失败。") : result.errorMessage); - return false; - } - - QString bubbleText = result.message; - if (plan.canRemember && rememberCheckBox->isChecked()) - { - RegisteredApp app = m_appLaunchManager->registeredAppFromPlan( - plan, - {plan.requestedName, plan.displayName}); - - bool replaced = false; - for (RegisteredApp &existingApp : config.apps) - { - if (existingApp.id == app.id) - { - existingApp = app; - replaced = true; - break; - } - } - if (!replaced) - { - config.apps.append(app); - } - - QString saveError; - if (!store.save(config, &saveError)) - { - Logger::warning(QStringLiteral("Failed to save launcher config after manual app selection: ") + saveError); - bubbleText += QStringLiteral("\n但保存应用别名失败。"); - } - } - - playState(QStringLiteral("talk"), false); - showBubbleMessage(bubbleText.isEmpty() ? QStringLiteral("应用已启动。") : bubbleText); - return true; -} - -void PetWindow::appendWorkspaceAgentMessage(const QString &title, const QString &content, bool error) -{ - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->appendAgentMessage(title, content, error); - } -} - -void PetWindow::appendWorkspaceUserTask(const QString &message) -{ - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->appendUserTask(message); - } -} - -void PetWindow::showWorkspacePanel() -{ - if (!m_workspaceAgentWindow) - { - return; - } - - refreshWorkspacePanel(); - m_workspaceAgentWindow->showWindow(); -} - -void PetWindow::refreshWorkspacePanel() -{ - if (!m_workspaceAgentWindow || !m_workspaceController) - { - return; - } - - m_workspaceAgentWindow->setSnapshot(m_workspaceController->snapshot()); -} - -void PetWindow::configureAgentHotkey() -{ - if (!m_globalHotkeyManager) - { - return; - } - - m_globalHotkeyManager->unregisterHotkey(); - if (!m_appConfig.agentHotkeyEnabled) - { - return; - } - - QString errorMessage; - if (!m_globalHotkeyManager->registerHotkey(m_appConfig.agentHotkeySequence, &errorMessage)) - { - Logger::warning(QStringLiteral("Failed to register Agent hotkey: ") + errorMessage); - if (isVisible()) - { - showBubbleMessage(errorMessage.isEmpty() - ? QStringLiteral("Agent 工作区快捷键注册失败。") - : QStringLiteral("Agent 工作区快捷键注册失败:") + errorMessage); - } - } -} - -void PetWindow::handleWorkspacePanelRead(int resultIndex) -{ - if (!m_workspaceController || hasActiveWorkspaceAIRequest()) - { - showBubbleMessage(QStringLiteral("工作区分析正在进行,请稍后。")); - return; - } - - consumeWorkspaceToolResult(m_workspaceController->readLastResult(resultIndex)); -} - -void PetWindow::handleWorkspacePanelOpen(int resultIndex) -{ - if (!m_workspaceController) - { - showBubbleMessage(QStringLiteral("本地工作区功能初始化失败。")); - return; - } - - consumeWorkspaceToolResult(m_workspaceController->openLastResult(resultIndex)); -} - -void PetWindow::handleWorkspacePanelReveal(int resultIndex) -{ - if (!m_workspaceController) - { - showBubbleMessage(QStringLiteral("本地工作区功能初始化失败。")); - return; - } - - consumeWorkspaceToolResult(m_workspaceController->revealLastResult(resultIndex)); -} - -void PetWindow::requestWorkspaceAiSummary(const WorkspaceToolResult &result) -{ - if (result.aiPrompt.trimmed().isEmpty()) - { - return; - } - - const bool otherAiBusy = hasActiveWebRequest() - || (m_conversationManager && m_conversationManager->isBusy()) - || m_streamingChatActive; - if (otherAiBusy) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("AI 暂不可用,已显示本地结果。")); - appendWorkspaceAgentMessage(QStringLiteral("工作区 AI 分析"), QStringLiteral("AI 暂不可用,已显示本地结果。"), true); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("就绪")); - } - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - return; - } - - ConfigManager configManager; - AIConfig config = configManager.loadAIConfig(); - QString errorMessage; - if (!AIProviderFactory::prepareRuntimeConfig(config, &errorMessage)) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("AI 暂不可用,已显示本地结果。")); - appendWorkspaceAgentMessage(QStringLiteral("工作区 AI 分析"), QStringLiteral("AI 暂不可用,已显示本地结果。"), true); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("就绪")); - } - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - return; - } - - std::unique_ptr provider = AIProviderFactory::createProvider(config); - if (!provider) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("AI 暂不可用,已显示本地结果。")); - appendWorkspaceAgentMessage(QStringLiteral("工作区 AI 分析"), QStringLiteral("AI 暂不可用,已显示本地结果。"), true); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("就绪")); - } - m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - return; - } - - ChatRequest request; - request.messages.append(ChatMessage{QStringLiteral("system"), WorkspaceAgent::systemPrompt()}); - request.messages.append(ChatMessage{QStringLiteral("user"), result.aiPrompt}); - - m_workspaceAiProvider = std::move(provider); - stopAnimationPrewarm(); - playState(QStringLiteral("think"), false); - showBubbleMessage(QStringLiteral("正在分析工作区...")); - if (m_workspaceAgentWindow) - { - m_workspaceAgentWindow->setStatusText(QStringLiteral("AI 分析中")); - } - - QPointer window(this); - m_workspaceAiProvider->sendChatRequest(request, [window, fallback = result.aiFallbackText](const ChatResponse &response) { - if (window.isNull()) - { - return; - } - - if (response.success) - { - const QString content = response.content.trimmed().isEmpty() ? fallback : response.content.trimmed(); - window->playState(QStringLiteral("talk"), false); - window->showBubbleMessage(QStringLiteral("工作区分析完成。")); - window->appendWorkspaceAgentMessage(QStringLiteral("工作区 AI 分析"), content); - if (window->m_appConfig.agentPanelAutoShow) - { - window->showWorkspacePanel(); - } - if (window->m_workspaceAgentWindow) - { - window->m_workspaceAgentWindow->setStatusText(QStringLiteral("就绪")); - } - window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - return; - } - - window->playState(QStringLiteral("talk"), false); - window->showBubbleMessage(QStringLiteral("AI 分析失败,已显示本地结果。")); - window->appendWorkspaceAgentMessage(QStringLiteral("工作区 AI 分析失败"), QStringLiteral("AI 分析失败,已显示本地结果。"), true); - if (window->m_workspaceAgentWindow) - { - window->m_workspaceAgentWindow->setStatusText(QStringLiteral("失败")); - } - window->m_behaviorReturnTimer.start(ChatFinishedReturnDelayMs); - }); -} bool PetWindow::submitWebChatMessage(const QString &message) { @@ -1571,13 +746,6 @@ bool PetWindow::submitWebChatMessage(const QString &message) return false; } - if (hasActiveWorkspaceAIRequest()) - { - playState(QStringLiteral("talk"), false); - showBubbleMessage(QStringLiteral("工作区分析正在进行,请稍后。")); - return false; - } - if ((m_conversationManager && m_conversationManager->isBusy()) || m_streamingChatActive) { playState(QStringLiteral("talk"), false); @@ -1681,11 +849,6 @@ bool PetWindow::hasActiveWebRequest() const return m_webChatManager && m_webChatManager->isBusy(); } -bool PetWindow::hasActiveWorkspaceAIRequest() const -{ - return m_workspaceAiProvider && m_workspaceAiProvider->isBusy(); -} - QString PetWindow::formatWebChatResponseForDisplay(const WebChatResponse &response) const { QString message = response.content.trimmed(); @@ -2009,12 +1172,6 @@ bool PetWindow::submitAiChatMessage(const QString &message) return false; } - if (hasActiveWorkspaceAIRequest()) - { - showBubbleMessage(QStringLiteral("工作区分析正在进行,请稍后。")); - return false; - } - if (!m_conversationManager || m_conversationManager->isBusy()) { showBubbleMessage(QStringLiteral("AI 回复正在进行。")); @@ -2124,10 +1281,6 @@ void PetWindow::clearConversation() { m_webChatManager->cancel(); } - if (m_workspaceAiProvider && m_workspaceAiProvider->isBusy()) - { - m_workspaceAiProvider->cancel(); - } refreshChatHistoryPanel(); showBubbleMessage(hadActiveRequest ? QStringLiteral("已取消当前请求,并清空对话。") @@ -2154,22 +1307,13 @@ void PetWindow::cancelActiveAIRequest() return; } - if (m_workspaceAiProvider && m_workspaceAiProvider->isBusy()) - { - m_workspaceAiProvider->cancel(); - showBubbleMessage(QStringLiteral("工作区分析已取消。")); - playState(QStringLiteral("idle"), false); - return; - } - showBubbleMessage(QStringLiteral("没有正在进行的 AI 或联网请求。")); } bool PetWindow::hasActiveAIRequest() const { return (m_conversationManager && m_conversationManager->isBusy()) - || hasActiveWebRequest() - || hasActiveWorkspaceAIRequest(); + || hasActiveWebRequest(); } bool PetWindow::isManualStateSwitchLocked() const diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index b475390..9cb5991 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -30,19 +30,12 @@ class ChatHistoryPanel; class ChatInputDialog; class ConversationManager; class ConversationStore; -class FileOperationManager; -class GlobalHotkeyManager; -class LLMProvider; -class AppLaunchManager; class NotificationDispatcher; class PetView; class ReminderManager; class ReminderSoundPlayer; class WebChatManager; class WeatherManager; -class WorkspaceController; -class WorkspaceAgentWindow; -struct WorkspaceToolResult; class PetWindow : public QWidget { @@ -59,8 +52,6 @@ public: void pauseAnimation(); void resumeAnimation(); void showBubbleMessage(const QString &message); - void showWorkspaceAgentWindowFromUserAction(); - void showWorkspacePanelFromUserAction(); protected: void contextMenuEvent(QContextMenuEvent *event) override; @@ -83,23 +74,8 @@ private: bool submitWebChatMessage(const QString &message); bool handleReminderChatMessage(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 hasActiveWorkspaceAIRequest() 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 saveWebTogglePreference(bool webEnabled); void handleTriggeredReminder(const ReminderItem &item); @@ -162,12 +138,6 @@ private: std::unique_ptr m_chatInputDialog; std::unique_ptr m_conversationManager; std::unique_ptr m_conversationStore; - std::unique_ptr m_fileOperationManager; - std::unique_ptr m_workspaceController; - std::unique_ptr m_workspaceAgentWindow; - std::unique_ptr m_workspaceAiProvider; - std::unique_ptr m_globalHotkeyManager; - std::unique_ptr m_appLaunchManager; std::unique_ptr m_notificationDispatcher; std::unique_ptr m_reminderManager; std::unique_ptr m_reminderSoundPlayer; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index cad52e0..0c4782b 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -4,7 +4,6 @@ #include "../ai/LLMTypes.h" #include "../character/CharacterPackageRepository.h" #include "../config/SecretStore.h" -#include "../launcher/AppLaunchManager.h" #include "../reminder/ReminderSoundRepository.h" #include "../web/WebCapabilityDetector.h" #include "../web/WebChatManager.h" @@ -21,15 +20,11 @@ #include #include #include -#include #include #include #include #include #include -#include -#include -#include #include #include #include @@ -228,68 +223,6 @@ QString webCapabilityDisplayText(const WebCapability &capability) return QStringLiteral("当前联网能力:%1。%2").arg(state, capability.userMessage); } -QString launchAppDisplayText(const RegisteredApp &app) -{ - QStringList lines; - lines.append(app.displayName); - if (!app.aliases.isEmpty()) - { - lines.append(QStringLiteral("别名:%1").arg(app.aliases.join(QStringLiteral(" / ")))); - } - if (!app.executablePath.trimmed().isEmpty()) - { - lines.append(QStringLiteral("路径:%1").arg(app.executablePath)); - } - else if (!app.shortcutPath.trimmed().isEmpty()) - { - lines.append(QStringLiteral("快捷方式:%1").arg(app.shortcutPath)); - } - return lines.join(QLatin1Char('\n')); -} - -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) { if (role == QStringLiteral("user")) @@ -541,7 +474,6 @@ SettingsDialog::SettingsDialog( const AppConfig &appConfig, const WeatherConfig &weatherConfig, const WebConfig &webConfig, - const AppLaunchConfig &appLaunchConfig, const QVector &conversationHistory, const QVector &reminders, std::function aiTestBlocked, @@ -594,19 +526,6 @@ SettingsDialog::SettingsDialog( , m_webShowCitationsCheckBox(new QCheckBox(QStringLiteral("显示来源引用"), this)) , m_testWebButton(new QPushButton(QStringLiteral("测试联网模式"), this)) , m_webStatusLabel(new QLabel(this)) - , m_launchEnabledCheckBox(new QCheckBox(QStringLiteral("启用应用启动"), this)) - , m_launchManualSelectionCheckBox(new QCheckBox(QStringLiteral("未知应用允许手选 exe"), this)) - , m_launchAppListWidget(new QListWidget(this)) - , m_addLaunchAppButton(new QPushButton(QStringLiteral("添加应用"), this)) - , m_editLaunchAppButton(new QPushButton(QStringLiteral("编辑选中"), this)) - , m_deleteLaunchAppButton(new QPushButton(QStringLiteral("删除选中"), this)) - , m_testLaunchAppButton(new QPushButton(QStringLiteral("测试启动"), this)) - , m_launchStatusLabel(new QLabel(this)) - , m_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_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this)) , m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this)) @@ -630,7 +549,6 @@ SettingsDialog::SettingsDialog( , m_appConfig(appConfig) , m_weatherConfig(weatherConfig) , m_webConfig(webConfig) - , m_appLaunchConfig(appLaunchConfig) , m_conversationHistory(conversationHistory) , m_reminders(reminders) , m_aiTestBlocked(std::move(aiTestBlocked)) @@ -639,7 +557,6 @@ SettingsDialog::SettingsDialog( , m_updateReminder(std::move(updateReminderCallback)) , m_clearFinishedReminders(std::move(clearFinishedRemindersCallback)) , m_playReminderSound(std::move(playReminderSoundCallback)) - , m_launchTestManager(std::make_unique()) , m_webTestManager(std::make_unique()) , m_weatherTestManager(std::make_unique()) { @@ -962,92 +879,6 @@ SettingsDialog::SettingsDialog( auto *webPage = new QWidget(this); webPage->setLayout(webPageLayout); - m_launchStatusLabel->setObjectName(QStringLiteral("HintLabel")); - m_launchStatusLabel->setWordWrap(true); - m_launchStatusLabel->setText(QStringLiteral("应用启动只支持 .exe 和开始菜单快捷方式;不执行脚本、命令、安装包,也不会拼接聊天文本参数。")); - m_launchEnabledCheckBox->setChecked(m_appLaunchConfig.enabled); - m_launchManualSelectionCheckBox->setChecked(m_appLaunchConfig.allowManualExeSelection); - m_launchAppListWidget->setObjectName(QStringLiteral("LaunchAppList")); - m_launchAppListWidget->setFrameShape(QFrame::NoFrame); - m_launchAppListWidget->setMinimumHeight(150); - m_launchAppListWidget->setSelectionMode(QAbstractItemView::SingleSelection); - reloadLaunchAppList(); - - auto *launchTitleLabel = new QLabel(QStringLiteral("应用启动"), this); - launchTitleLabel->setObjectName(QStringLiteral("PageTitle")); - auto *launchHintLabel = new QLabel(QStringLiteral("通过聊天输入“打开 Codex / 启动酷狗音乐”等触发。未知应用可手选 exe,启动前始终二次确认。"), this); - launchHintLabel->setObjectName(QStringLiteral("HintLabel")); - launchHintLabel->setWordWrap(true); - - auto *launchFormLayout = new QFormLayout(); - launchFormLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); - launchFormLayout->setLabelAlignment(Qt::AlignRight); - launchFormLayout->setHorizontalSpacing(18); - launchFormLayout->setVerticalSpacing(12); - launchFormLayout->addRow(QString(), m_launchEnabledCheckBox); - launchFormLayout->addRow(QString(), m_launchManualSelectionCheckBox); - - auto *launchActionLayout = new QHBoxLayout(); - launchActionLayout->addWidget(m_addLaunchAppButton); - launchActionLayout->addWidget(m_editLaunchAppButton); - launchActionLayout->addWidget(m_deleteLaunchAppButton); - launchActionLayout->addWidget(m_testLaunchAppButton); - launchActionLayout->addWidget(m_launchStatusLabel, 1); - - auto *launchPageLayout = new QVBoxLayout(); - launchPageLayout->setContentsMargins(24, 24, 24, 24); - launchPageLayout->setSpacing(16); - launchPageLayout->addWidget(launchTitleLabel); - launchPageLayout->addWidget(launchHintLabel); - launchPageLayout->addLayout(launchFormLayout); - launchPageLayout->addWidget(m_launchAppListWidget); - launchPageLayout->addLayout(launchActionLayout); - launchPageLayout->addStretch(); - - auto *launchPage = new QWidget(this); - launchPage->setLayout(launchPageLayout); - - m_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->setWordWrap(true); @@ -1164,8 +995,6 @@ SettingsDialog::SettingsDialog( 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("角色")); @@ -1176,8 +1005,6 @@ SettingsDialog::SettingsDialog( pageStack->addWidget(chatPage); pageStack->addWidget(weatherPage); pageStack->addWidget(webPage); - pageStack->addWidget(launchPage); - pageStack->addWidget(agentPage); pageStack->addWidget(reminderPage); pageStack->addWidget(appPage); pageStack->addWidget(characterPage); @@ -1229,22 +1056,12 @@ SettingsDialog::SettingsDialog( " background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;" " padding: 0px;" "}" - "QListWidget#LaunchAppList {" - " background: #ffffff; border: 1px solid #98a2b3; border-radius: 0px;" - " padding: 0px;" - "}" "QListWidget#ReminderList::item {" " min-height: 28px; padding: 5px 8px; color: #344054;" "}" - "QListWidget#LaunchAppList::item {" - " min-height: 34px; padding: 6px 8px; color: #344054;" - "}" "QListWidget#ReminderList::item:selected {" " background: #eaf3ff; color: #175cd3;" "}" - "QListWidget#LaunchAppList::item:selected {" - " background: #eaf3ff; color: #175cd3;" - "}" "QStackedWidget#SettingsPages {" " background: #ffffff; border: 1px solid #eaecf0; border-radius: 8px;" "}" @@ -1302,26 +1119,6 @@ SettingsDialog::SettingsDialog( connect(m_testWebButton, &QPushButton::clicked, this, [this]() { testWebMode(); }); - connect(m_launchAppListWidget, &QListWidget::currentItemChanged, this, [this]() { - updateLaunchAppButtons(); - }); - connect(m_addLaunchAppButton, &QPushButton::clicked, this, [this]() { - addLaunchApp(); - }); - connect(m_editLaunchAppButton, &QPushButton::clicked, this, [this]() { - editSelectedLaunchApp(); - }); - connect(m_deleteLaunchAppButton, &QPushButton::clicked, this, [this]() { - deleteSelectedLaunchApp(); - }); - connect(m_testLaunchAppButton, &QPushButton::clicked, this, [this]() { - testSelectedLaunchApp(); - }); - connect(m_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]() { importCharacterFolder(); }); @@ -1363,7 +1160,6 @@ SettingsDialog::SettingsDialog( }); updateReminderActionButtons(); updateReminderSoundButtons(); - updateLaunchAppButtons(); refreshWebCapabilityStatus(); } @@ -1431,13 +1227,6 @@ AppConfig SettingsDialog::appConfig() const } config.reminderSoundEnabled = m_reminderSoundEnabledCheckBox->isChecked(); 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; } @@ -1468,15 +1257,6 @@ WebConfig SettingsDialog::webConfig() const return config; } -AppLaunchConfig SettingsDialog::appLaunchConfig() const -{ - AppLaunchConfig config = m_appLaunchConfig; - config.enabled = m_launchEnabledCheckBox->isChecked(); - config.allowManualExeSelection = m_launchManualSelectionCheckBox->isChecked(); - config.alwaysConfirmBeforeLaunch = true; - return config; -} - void SettingsDialog::testWeatherDefaultCity() { const QString cityName = m_weatherDefaultCityEdit->text().trimmed(); @@ -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() { @@ -1836,15 +1389,6 @@ void SettingsDialog::accept() 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; QString errorMessage; if (!buildAIConfigStore(&store, &errorMessage)) diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index 13332c1..8460385 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -3,7 +3,6 @@ #include "../config/AIConfig.h" #include "../config/AppConfig.h" #include "../ai/LLMTypes.h" -#include "../launcher/AppLaunchTypes.h" #include "../reminder/ReminderTypes.h" #include "../web/WebConfig.h" #include "../weather/WeatherConfig.h" @@ -20,11 +19,9 @@ class QDoubleSpinBox; class QLabel; class QLineEdit; class QListWidget; -class QKeySequenceEdit; class QPushButton; class QSpinBox; class LLMProvider; -class AppLaunchManager; class WebChatManager; class WeatherManager; @@ -36,7 +33,6 @@ public: const AppConfig &appConfig, const WeatherConfig &weatherConfig, const WebConfig &webConfig, - const AppLaunchConfig &appLaunchConfig, const QVector &conversationHistory, const QVector &reminders, std::function aiTestBlocked, @@ -52,7 +48,6 @@ public: AppConfig appConfig() const; WeatherConfig weatherConfig() const; WebConfig webConfig() const; - AppLaunchConfig appLaunchConfig() const; protected: void accept() override; @@ -91,13 +86,6 @@ private: void testWeatherDefaultCity(); void refreshWebCapabilityStatus(); void testWebMode(); - void reloadLaunchAppList(); - void updateLaunchAppButtons(); - QString selectedLaunchAppId() const; - void addLaunchApp(); - void editSelectedLaunchApp(); - void deleteSelectedLaunchApp(); - void testSelectedLaunchApp(); QComboBox *m_providerComboBox = nullptr; QLineEdit *m_baseUrlEdit = nullptr; @@ -143,19 +131,6 @@ private: QCheckBox *m_webShowCitationsCheckBox = nullptr; QPushButton *m_testWebButton = nullptr; QLabel *m_webStatusLabel = nullptr; - QCheckBox *m_launchEnabledCheckBox = nullptr; - QCheckBox *m_launchManualSelectionCheckBox = nullptr; - QListWidget *m_launchAppListWidget = nullptr; - QPushButton *m_addLaunchAppButton = nullptr; - QPushButton *m_editLaunchAppButton = nullptr; - QPushButton *m_deleteLaunchAppButton = nullptr; - QPushButton *m_testLaunchAppButton = nullptr; - QLabel *m_launchStatusLabel = nullptr; - QCheckBox *m_agentHotkeyEnabledCheckBox = nullptr; - QKeySequenceEdit *m_agentHotkeyEdit = nullptr; - QPushButton *m_agentResetHotkeyButton = nullptr; - QCheckBox *m_agentPanelAutoShowCheckBox = nullptr; - QLabel *m_agentStatusLabel = nullptr; QComboBox *m_characterComboBox = nullptr; QPushButton *m_importCharacterButton = nullptr; QPushButton *m_deleteCharacterButton = nullptr; @@ -180,7 +155,6 @@ private: AppConfig m_appConfig; WeatherConfig m_weatherConfig; WebConfig m_webConfig; - AppLaunchConfig m_appLaunchConfig; QVector m_conversationHistory; QVector m_reminders; QString m_currentProvider; @@ -191,7 +165,6 @@ private: std::function m_clearFinishedReminders; std::function m_playReminderSound; std::unique_ptr m_testProvider; - std::unique_ptr m_launchTestManager; std::unique_ptr m_webTestManager; std::unique_ptr m_weatherTestManager; bool m_hasAcceptedConfigStore = false; diff --git a/src/ui/WorkspaceAgentWindow.cpp b/src/ui/WorkspaceAgentWindow.cpp deleted file mode 100644 index 799bc46..0000000 --- a/src/ui/WorkspaceAgentWindow.cpp +++ /dev/null @@ -1,676 +0,0 @@ -#include "WorkspaceAgentWindow.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -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("
")); -} - -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( - "" - "
" - "" - "
" - "
%5
" - "
%7
" - "
" - "
" - "

") - .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("
请选择左侧文件结果。
")); - } - else - { - const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index); - m_selectedDetailEdit->setHtml(QStringLiteral( - "
%1
" - "
类型:%2
大小:%3 字节
修改时间:%4
绝对路径:%5
") - .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("
暂无最近读取文件。
")); - } - else - { - m_recentReadEdit->setHtml(QStringLiteral( - "
%1
" - "
%2
") - .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( - "
%1:%2
" - "
%3
" - "
%4
") - .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("
暂无文本搜索命中。
")); - } - 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("
%1:%2
%3
") - .arg(htmlEscaped(match.relativePath)) - .arg(match.lineNumber) - .arg(htmlEscaped(match.lineText)); - } - m_textMatchDetailEdit->setHtml(matchHtml); - } - m_planEdit->setHtml(QStringLiteral("
v1b 阶段保持只读,没有待确认变更。v1c 写操作会在这里展示计划和 Diff。
")); -} - -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; -} diff --git a/src/ui/WorkspaceAgentWindow.h b/src/ui/WorkspaceAgentWindow.h deleted file mode 100644 index 7d7485e..0000000 --- a/src/ui/WorkspaceAgentWindow.h +++ /dev/null @@ -1,89 +0,0 @@ -#pragma once - -#include "../workspace/WorkspaceTypes.h" - -#include -#include - -#include - -class QLabel; -class QLineEdit; -class QListWidget; -class QPushButton; -class QTextEdit; -class QCloseEvent; -class QMouseEvent; - -class WorkspaceAgentWindow : public QWidget -{ -public: - using MessageCallback = std::function; - using SimpleCallback = std::function; - using ResultActionCallback = std::function; - - 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; -}; diff --git a/src/ui/WorkspacePanel.cpp b/src/ui/WorkspacePanel.cpp deleted file mode 100644 index 5cff641..0000000 --- a/src/ui/WorkspacePanel.cpp +++ /dev/null @@ -1,506 +0,0 @@ -#include "WorkspacePanel.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -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(intersection.width()) * static_cast(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("
")); -} - -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 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::max(); - qint64 bestDistance = std::numeric_limits::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("
请选择左侧结果。
")); - return; - } - - const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index); - const QString html = QStringLiteral( - "
%1
" - "
类型:%2
大小:%3 字节
修改时间:%4
") - .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("
暂无文本搜索命中。
")); - 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("
%1:%2
%3
") - .arg(htmlLine(match.relativePath)) - .arg(match.lineNumber) - .arg(htmlLine(match.lineText)); - } - if (m_snapshot.lastTextMatches.size() > matchCount) - { - html += QStringLiteral("
仅显示前 %1 条文本命中。
").arg(matchCount); - } - m_textMatchesEdit->setHtml(html); -} - -void WorkspacePanel::updateRecentRead() -{ - if (!m_snapshot.hasLastReadFile) - { - m_recentReadEdit->setHtml(QStringLiteral("
暂无最近读取文件。
")); - return; - } - - const QString html = QStringLiteral( - "
%1
" - "
%2
") - .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; -} diff --git a/src/ui/WorkspacePanel.h b/src/ui/WorkspacePanel.h deleted file mode 100644 index 1570e3e..0000000 --- a/src/ui/WorkspacePanel.h +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include "../workspace/WorkspaceTypes.h" - -#include -#include -#include -#include - -#include - -class QLabel; -class QListWidget; -class QPushButton; -class QTextEdit; - -class WorkspacePanel : public QDialog -{ -public: - using ResultActionCallback = std::function; - - 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; -}; diff --git a/src/util/ResourcePaths.cpp b/src/util/ResourcePaths.cpp index 1abf6c9..a9ffbdc 100644 --- a/src/util/ResourcePaths.cpp +++ b/src/util/ResourcePaths.cpp @@ -27,17 +27,6 @@ QString sourceResourcesRootPath() } } -QString ResourcePaths::resourcesRootPath() -{ - const QString appResources = appResourcesRootPath(); - if (QFileInfo::exists(appResources)) - { - return appResources; - } - - return sourceResourcesRootPath(); -} - QString ResourcePaths::resourcePath(const QString &relativePath) { const QString cleanedRelativePath = cleanRelativePath(relativePath); @@ -64,8 +53,3 @@ QString ResourcePaths::appIconPath() { return resourcePath(QStringLiteral("icons/app_icon.ico")); } - -QString ResourcePaths::appIconSourcePngPath() -{ - return resourcePath(QStringLiteral("icons/app_icon_1024.png")); -} diff --git a/src/util/ResourcePaths.h b/src/util/ResourcePaths.h index cbf3c23..c1517dc 100644 --- a/src/util/ResourcePaths.h +++ b/src/util/ResourcePaths.h @@ -5,10 +5,8 @@ class ResourcePaths { public: - static QString resourcesRootPath(); static QString resourcePath(const QString &relativePath); static QString charactersRootPath(); static QString reminderSoundsRootPath(); static QString appIconPath(); - static QString appIconSourcePngPath(); }; diff --git a/src/workspace/WorkspaceAccessPolicy.cpp b/src/workspace/WorkspaceAccessPolicy.cpp deleted file mode 100644 index fdc06bb..0000000 --- a/src/workspace/WorkspaceAccessPolicy.cpp +++ /dev/null @@ -1,371 +0,0 @@ -#include "WorkspaceAccessPolicy.h" - -#include -#include -#include -#include - -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; -} diff --git a/src/workspace/WorkspaceAccessPolicy.h b/src/workspace/WorkspaceAccessPolicy.h deleted file mode 100644 index 430b676..0000000 --- a/src/workspace/WorkspaceAccessPolicy.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -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; -}; diff --git a/src/workspace/WorkspaceAgent.cpp b/src/workspace/WorkspaceAgent.cpp deleted file mode 100644 index 16febcb..0000000 --- a/src/workspace/WorkspaceAgent.cpp +++ /dev/null @@ -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)); -} diff --git a/src/workspace/WorkspaceAgent.h b/src/workspace/WorkspaceAgent.h deleted file mode 100644 index e0c59f9..0000000 --- a/src/workspace/WorkspaceAgent.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#include - -class WorkspaceAgent -{ -public: - static QString systemPrompt(); - static QString projectAnalysisPrompt(const QString &workspaceRoot, const QString &structureSummary); - static QString fileSummaryPrompt(const QString &relativePath, const QString &content); -}; diff --git a/src/workspace/WorkspaceController.cpp b/src/workspace/WorkspaceController.cpp deleted file mode 100644 index 33fbe5d..0000000 --- a/src/workspace/WorkspaceController.cpp +++ /dev/null @@ -1,1061 +0,0 @@ -#include "WorkspaceController.h" - -#include "WorkspaceAgent.h" -#include "tools/ListFilesTool.h" -#include "tools/OpenDirectoryTool.h" -#include "tools/OpenFileTool.h" -#include "tools/ReadFileTool.h" -#include "tools/RevealInFileManagerTool.h" -#include "tools/SearchFilesTool.h" -#include "tools/SearchTextTool.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace -{ -constexpr int MaxFileSearchResults = 30; -constexpr int MaxReadCandidateResults = 8; -constexpr int MaxOpenCandidateResults = 8; - -WorkspaceToolResult failedResult(const QString &message) -{ - WorkspaceToolResult result; - result.errorMessage = message; - return result; -} - -bool containsAny(const QString &text, const QStringList &keywords) -{ - for (const QString &keyword : keywords) - { - if (text.contains(keyword, Qt::CaseInsensitive)) - { - return true; - } - } - return false; -} - -QString removeNoiseWords(QString text) -{ - static const QStringList noiseWords = { - 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("的"), - }; - - for (const QString &word : noiseWords) - { - text.remove(word, Qt::CaseInsensitive); - } - return text.trimmed(); -} - -bool containsFileAnalysisVerb(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("做什么"), - }); -} - -QString removeFileAnalysisWords(QString text) -{ - static const QStringList phrases = { - QStringLiteral("是干嘛的"), - QStringLiteral("是做什么的"), - QStringLiteral("有什么作用"), - 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("做什么"), - }; - - for (const QString &phrase : phrases) - { - text.remove(phrase, Qt::CaseInsensitive); - } - return removeNoiseWords(text); -} - -bool containsTextSearchTargetContext(const QString &text) -{ - return containsAny(text, { - QStringLiteral("文件"), - QStringLiteral("代码"), - QStringLiteral("源码"), - QStringLiteral("项目"), - QStringLiteral("工作区"), - }); -} - -bool containsTextSearchPattern(const QString &text) -{ - if (!containsTextSearchTargetContext(text)) - { - 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; - } - - static const QStringList targetContexts = { - QStringLiteral("文件"), - QStringLiteral("代码"), - QStringLiteral("源码"), - QStringLiteral("项目"), - QStringLiteral("工作区"), - }; - 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); -} - -QString extractAfterFirstContentMarker(const QString &message) -{ - struct Marker - { - QString text; - qsizetype index = -1; - }; - - QVector markers = { - {QStringLiteral("包含"), message.indexOf(QStringLiteral("包含"), 0, Qt::CaseInsensitive)}, - {QStringLiteral("带有"), message.indexOf(QStringLiteral("带有"), 0, Qt::CaseInsensitive)}, - {QStringLiteral("含"), message.indexOf(QStringLiteral("含"), 0, Qt::CaseInsensitive)}, - {QStringLiteral("有"), message.indexOf(QStringLiteral("有"), 0, Qt::CaseInsensitive)}, - }; - - Marker best; - for (const Marker &marker : markers) - { - if (marker.index < 0) - { - continue; - } - if (best.index < 0 || marker.index < best.index) - { - best = marker; - } - } - - if (best.index < 0) - { - return QString(); - } - - QString keyword = message.mid(best.index + best.text.size()); - const int deIndex = keyword.indexOf(QStringLiteral("的"), 0, Qt::CaseInsensitive); - if (deIndex > 0) - { - keyword = keyword.left(deIndex); - } - return removeNoiseWords(keyword); -} - -} - -WorkspaceController::WorkspaceController() - : m_scanner(8, 5000) -{ -} - -bool WorkspaceController::openWorkspace(const QString &rootPath, QString *errorMessage) -{ - if (!WorkspaceAccessPolicy::validateWorkspaceRoot(rootPath, errorMessage)) - { - return false; - } - - const QString normalizedRoot = WorkspaceAccessPolicy(rootPath).workspaceRoot(); - m_session.setWorkspaceRoot(normalizedRoot); - WorkspaceToolResult result = scanWorkspace(); - if (!result.success && errorMessage != nullptr) - { - *errorMessage = result.errorMessage; - } - return result.success; -} - -void WorkspaceController::closeWorkspace() -{ - m_session.clear(); -} - -bool WorkspaceController::hasWorkspace() const -{ - return m_session.hasWorkspace(); -} - -QString WorkspaceController::workspaceRoot() const -{ - return m_session.workspaceRoot(); -} - -WorkspaceToolResult WorkspaceController::scanWorkspace() -{ - if (!m_session.hasWorkspace()) - { - return failedResult(QStringLiteral("请先选择工作区。")); - } - - QString errorMessage; - const QVector files = m_scanner.scan(m_session.workspaceRoot(), &errorMessage); - if (!errorMessage.isEmpty()) - { - return failedResult(errorMessage); - } - - m_session.index().setFiles(files); - m_session.setLastScanAt(QDateTime::currentDateTime()); - - WorkspaceToolResult result; - result.success = true; - result.message = QStringLiteral("已扫描工作区,索引 %1 项。").arg(files.size()); - result.files = files; - result.outputText = projectSummary(false).outputText; - return result; -} - -WorkspaceToolResult WorkspaceController::handleMessage(const QString &message) -{ - const QString text = message.trimmed(); - - if (containsAny(text, { - QStringLiteral("执行"), - QStringLiteral("运行"), - QStringLiteral("命令"), - QStringLiteral("脚本"), - QStringLiteral("安装依赖"), - QStringLiteral("git commit"), - QStringLiteral("git 提交"), - })) - { - return failedResult(QStringLiteral("本地工作区 Agent 不支持执行命令、运行脚本、安装依赖或自动 git 操作。")); - } - - if (containsAny(text, { - QStringLiteral("创建"), - QStringLiteral("新建"), - QStringLiteral("写入"), - QStringLiteral("修改"), - QStringLiteral("修复"), - QStringLiteral("优化"), - QStringLiteral("覆盖"), - QStringLiteral("删除"), - QStringLiteral("移动"), - QStringLiteral("重命名"), - })) - { - return failedResult(QStringLiteral("当前工作区阶段不支持创建、新建、写入、修改、修复、优化、覆盖、删除、移动或重命名文件。")); - } - - if (!m_session.hasWorkspace()) - { - return failedResult(QStringLiteral("请先选择工作区。")); - } - - if (containsAny(text, {QStringLiteral("当前工作区"), QStringLiteral("工作区状态")})) - { - return currentWorkspaceInfo(); - } - - if (containsAny(text, {QStringLiteral("扫描工作区"), QStringLiteral("重新扫描"), QStringLiteral("刷新工作区")})) - { - return scanWorkspace(); - } - - if (containsAny(text, {QStringLiteral("列出"), QStringLiteral("目录结构"), QStringLiteral("文件树")}) - && !containsAny(text, {QStringLiteral("搜索"), QStringLiteral("查找")})) - { - return listWorkspaceRoot(); - } - - if (isProjectSummaryMessage(text)) - { - return projectSummary(containsAny(text, {QStringLiteral("分析"), QStringLiteral("总结")})); - } - - if (isRevealMessage(text)) - { - return openItemByQuery(extractOpenTarget(text), true); - } - - if (isOpenMessage(text)) - { - return openItemByQuery(extractOpenTarget(text), false); - } - - if (isTextSearchMessage(text)) - { - return searchText(extractTextSearchKeyword(text)); - } - - if (containsAny(text, {QStringLiteral("读取"), QStringLiteral("读一下")}) - || isFileSummaryMessage(text)) - { - return readFileByQuery(extractReadTarget(text), isFileSummaryMessage(text)); - } - - if (containsAny(text, {QStringLiteral("搜索"), QStringLiteral("查找"), QStringLiteral("找一下")})) - { - return searchFiles(extractSearchKeyword(text)); - } - - WorkspaceToolResult result; - result.success = true; - result.message = QStringLiteral("可以搜索、读取或分析当前工作区。"); - result.outputText = QStringLiteral( - "本地工作区 Agent 支持:\n" - "- 扫描工作区\n" - "- 搜索 README / 搜索 main.cpp\n" - "- 搜索包含 class 的文件 / 含class的文件\n" - "- 读取 main.cpp\n" - "- 打开 README / 打开第一个\n" - "- 显示 main.cpp 所在位置\n" - "- 分析这个项目\n\n" - "当前不支持创建、修改、移动、删除或执行命令。"); - return result; -} - -WorkspaceToolResult WorkspaceController::executeActionPlan(const WorkspaceActionPlan &plan) -{ - if (!m_session.hasWorkspace()) - { - return failedResult(QStringLiteral("请先选择工作区。")); - } - - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - if (plan.type == WorkspaceActionType::OpenFile) - { - return OpenFileTool(policy).execute(plan); - } - - return failedResult(QStringLiteral("当前工作区动作暂不支持执行。")); -} - -WorkspaceToolResult WorkspaceController::readLastResult(int zeroBasedIndex) -{ - WorkspaceFileInfo file; - WorkspaceToolResult indexedResult = itemFromLastResults(zeroBasedIndex, &file); - if (!indexedResult.success) - { - return indexedResult; - } - if (file.isDirectory) - { - return failedResult(QStringLiteral("选中项是目录,不能作为文本文件读取。")); - } - - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - ReadFileTool reader(policy); - WorkspaceToolResult result = reader.run(file); - if (result.success && !result.files.isEmpty()) - { - m_session.setLastReadFile(result.files.first(), result.outputText); - } - return result; -} - -WorkspaceToolResult WorkspaceController::openLastResult(int zeroBasedIndex) -{ - WorkspaceFileInfo file; - WorkspaceToolResult indexedResult = itemFromLastResults(zeroBasedIndex, &file); - if (!indexedResult.success) - { - return indexedResult; - } - - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - if (file.isDirectory) - { - return OpenDirectoryTool(policy).run(file); - } - return OpenFileTool(policy).prepare(file); -} - -WorkspaceToolResult WorkspaceController::revealLastResult(int zeroBasedIndex) -{ - WorkspaceFileInfo file; - WorkspaceToolResult indexedResult = itemFromLastResults(zeroBasedIndex, &file); - if (!indexedResult.success) - { - return indexedResult; - } - - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - return RevealInFileManagerTool(policy).run(file); -} - -WorkspacePanelSnapshot WorkspaceController::snapshot() const -{ - WorkspacePanelSnapshot snapshot; - snapshot.workspaceRoot = m_session.workspaceRoot(); - snapshot.lastScanAt = m_session.lastScanAt(); - snapshot.indexedItemCount = m_session.index().size(); - snapshot.lastResults = m_session.lastResults(); - snapshot.lastTextMatches = m_session.lastTextMatches(); - snapshot.hasLastReadFile = m_session.hasLastReadFile(); - if (snapshot.hasLastReadFile) - { - snapshot.lastReadFile = m_session.lastReadFile(); - snapshot.lastReadPreview = m_session.lastReadContent().left(12000); - } - return snapshot; -} - -QVector WorkspaceController::lastResults() const -{ - return m_session.lastResults(); -} - -WorkspaceToolResult WorkspaceController::currentWorkspaceInfo() const -{ - WorkspaceToolResult result; - result.success = true; - result.message = QStringLiteral("当前工作区已就绪。"); - result.outputText = QStringLiteral("当前工作区:%1\n索引项:%2\n最近扫描:%3") - .arg(m_session.workspaceRoot()) - .arg(m_session.index().size()) - .arg(m_session.lastScanAt().isValid() - ? m_session.lastScanAt().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) - : QStringLiteral("尚未扫描")); - return result; -} - -WorkspaceToolResult WorkspaceController::listWorkspaceRoot() const -{ - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - return ListFilesTool(policy).run(); -} - -WorkspaceToolResult WorkspaceController::searchFiles(const QString &keyword) -{ - SearchFilesTool tool(m_session.index()); - WorkspaceToolResult result = tool.run(keyword, MaxFileSearchResults); - if (result.success) - { - m_session.setLastResults(result.files); - m_session.setLastTextMatches({}); - } - return result; -} - -WorkspaceToolResult WorkspaceController::searchText(const QString &keyword) -{ - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - SearchTextTool tool(policy, m_session.index()); - WorkspaceToolResult result = tool.run(keyword); - if (result.success) - { - QVector matchedFiles; - QSet seenFiles; - const QVector allFiles = m_session.index().allFiles(); - for (const WorkspaceTextMatch &match : result.textMatches) - { - if (seenFiles.contains(match.absolutePath)) - { - continue; - } - - auto found = std::find_if(allFiles.begin(), allFiles.end(), [&match](const WorkspaceFileInfo &file) { - return file.absolutePath == match.absolutePath; - }); - if (found != allFiles.end()) - { - matchedFiles.append(*found); - seenFiles.insert(match.absolutePath); - } - } - m_session.setLastResults(matchedFiles); - m_session.setLastTextMatches(result.textMatches); - } - return result; -} - -WorkspaceToolResult WorkspaceController::readFileByQuery(const QString &query, bool requestSummary) -{ - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - ReadFileTool reader(policy); - - const int index = ordinalIndex(query); - if (index >= 0) - { - const QVector last = m_session.lastResults(); - if (index >= last.size()) - { - return failedResult(QStringLiteral("上一轮结果没有第 %1 项。").arg(index + 1)); - } - if (last.at(index).isDirectory) - { - return failedResult(QStringLiteral("第 %1 项是目录,请使用“打开第%1个”打开文件夹。").arg(index + 1)); - } - - WorkspaceToolResult result = reader.run(last.at(index)); - if (result.success && !result.files.isEmpty()) - { - m_session.setLastReadFile(result.files.first(), result.outputText); - } - if (requestSummary && result.success && !result.files.isEmpty()) - { - result.shouldRequestAiSummary = true; - result.aiFallbackText = result.outputText.left(4000); - result.aiPrompt = WorkspaceAgent::fileSummaryPrompt(result.files.first().relativePath, result.outputText); - result.message = QStringLiteral("已读取文件,正在准备总结。"); - } - return result; - } - - QString target = query.trimmed(); - if (target.isEmpty() && requestSummary && m_session.hasLastReadFile()) - { - WorkspaceToolResult result; - result.success = true; - result.message = QStringLiteral("已使用最近读取的文件生成总结上下文。"); - result.outputText = m_session.lastReadContent(); - result.files.append(m_session.lastReadFile()); - result.shouldRequestAiSummary = true; - result.aiFallbackText = result.outputText.left(4000); - result.aiPrompt = WorkspaceAgent::fileSummaryPrompt(m_session.lastReadFile().relativePath, result.outputText); - return result; - } - if (target.isEmpty() && requestSummary) - { - return failedResult(QStringLiteral("请先读取一个工作区文件,或输入:总结 main.cpp。")); - } - if (target.isEmpty()) - { - return failedResult(QStringLiteral("请说明要读取的工作区文件,例如:读取 main.cpp。")); - } - - const QVector rawMatches = m_session.index().findByName(target, MaxReadCandidateResults); - QVector matches; - for (const WorkspaceFileInfo &file : rawMatches) - { - if (!file.isDirectory && file.isTextFile) - { - matches.append(file); - } - } - - if (matches.isEmpty()) - { - return failedResult(QStringLiteral("没有找到可读取的文本文件:%1").arg(target)); - } - if (matches.size() > 1) - { - WorkspaceToolResult result; - result.success = true; - result.files = matches; - m_session.setLastResults(matches); - m_session.setLastTextMatches({}); - QStringList lines; - lines.append(QStringLiteral("找到多个匹配文件,请再说“读取第几个”:")); - for (int i = 0; i < matches.size(); ++i) - { - lines.append(QStringLiteral("%1. %2").arg(i + 1).arg(matches.at(i).relativePath)); - } - result.message = QStringLiteral("找到多个匹配文件。"); - result.outputText = lines.join(QLatin1Char('\n')); - return result; - } - - WorkspaceToolResult result = reader.run(matches.first()); - if (result.success && !result.files.isEmpty()) - { - m_session.setLastReadFile(result.files.first(), result.outputText); - } - if (requestSummary && result.success && !result.files.isEmpty()) - { - result.shouldRequestAiSummary = true; - result.aiFallbackText = result.outputText.left(4000); - result.aiPrompt = WorkspaceAgent::fileSummaryPrompt(result.files.first().relativePath, result.outputText); - result.message = QStringLiteral("已读取文件,正在准备总结。"); - } - return result; -} - -WorkspaceToolResult WorkspaceController::openItemByQuery(const QString &query, bool revealInFileManager) -{ - const WorkspaceAccessPolicy policy(m_session.workspaceRoot()); - - const auto runForItem = [&policy, revealInFileManager](const WorkspaceFileInfo &item) { - if (revealInFileManager) - { - return RevealInFileManagerTool(policy).run(item); - } - if (item.isDirectory) - { - return OpenDirectoryTool(policy).run(item); - } - return OpenFileTool(policy).prepare(item); - }; - - const int index = ordinalIndex(query); - if (index >= 0) - { - const QVector last = m_session.lastResults(); - if (index >= last.size()) - { - return failedResult(QStringLiteral("上一轮结果没有第 %1 项。").arg(index + 1)); - } - return runForItem(last.at(index)); - } - - QString target = query.trimmed(); - if (target.isEmpty() && revealInFileManager && m_session.hasLastReadFile()) - { - return RevealInFileManagerTool(policy).run(m_session.lastReadFile()); - } - if (target.isEmpty()) - { - return failedResult(revealInFileManager - ? QStringLiteral("请说明要显示位置的工作区文件,例如:显示 main.cpp 所在位置。") - : QStringLiteral("请说明要打开的工作区文件或文件夹,例如:打开 README。")); - } - - const QVector matches = m_session.index().findByName(target, MaxOpenCandidateResults); - if (matches.isEmpty()) - { - return failedResult(QStringLiteral("没有找到匹配的工作区项目:%1").arg(target)); - } - - QVector exactMatches; - const QString normalizedTarget = target.trimmed().replace(QLatin1Char('\\'), QLatin1Char('/')); - for (const WorkspaceFileInfo &match : matches) - { - if (match.relativePath.compare(normalizedTarget, Qt::CaseInsensitive) == 0 - || match.fileName.compare(normalizedTarget, Qt::CaseInsensitive) == 0 - || QFileInfo(match.fileName).completeBaseName().compare(normalizedTarget, Qt::CaseInsensitive) == 0) - { - exactMatches.append(match); - } - } - if (exactMatches.size() == 1) - { - return runForItem(exactMatches.first()); - } - - if (matches.size() > 1) - { - WorkspaceToolResult result; - result.success = true; - result.files = matches; - m_session.setLastResults(matches); - m_session.setLastTextMatches({}); - QStringList lines; - lines.append(revealInFileManager - ? QStringLiteral("找到多个匹配项,请再说“显示第几个所在位置”:") - : QStringLiteral("找到多个匹配项,请再说“打开第几个”:")); - for (int i = 0; i < matches.size(); ++i) - { - lines.append(QStringLiteral("%1. %2%3") - .arg(i + 1) - .arg(matches.at(i).relativePath) - .arg(matches.at(i).isDirectory ? QStringLiteral(" [目录]") : QString())); - } - result.message = QStringLiteral("找到多个匹配项。"); - result.outputText = lines.join(QLatin1Char('\n')); - return result; - } - - return runForItem(matches.first()); -} - -WorkspaceToolResult WorkspaceController::itemFromLastResults(int zeroBasedIndex, WorkspaceFileInfo *file) const -{ - if (!m_session.hasWorkspace()) - { - return failedResult(QStringLiteral("请先选择工作区。")); - } - - const QVector last = m_session.lastResults(); - if (zeroBasedIndex < 0 || zeroBasedIndex >= last.size()) - { - return failedResult(QStringLiteral("请选择有效的工作区结果。")); - } - - if (file != nullptr) - { - *file = last.at(zeroBasedIndex); - } - - WorkspaceToolResult result; - result.success = true; - return result; -} - -WorkspaceToolResult WorkspaceController::projectSummary(bool requestAiSummary) const -{ - const QVector files = m_session.index().allFiles(); - int directoryCount = 0; - int fileCount = 0; - int textFileCount = 0; - QMap suffixCounts; - QStringList topLevelDirectories; - QStringList topLevelFiles; - - for (const WorkspaceFileInfo &file : files) - { - const QStringList parts = file.relativePath.split(QLatin1Char('/'), Qt::SkipEmptyParts); - if (file.isDirectory) - { - ++directoryCount; - if (parts.size() == 1 && topLevelDirectories.size() < 20) - { - topLevelDirectories.append(file.relativePath); - } - continue; - } - - ++fileCount; - if (file.isTextFile) - { - ++textFileCount; - } - if (!file.suffix.isEmpty()) - { - suffixCounts[file.suffix.toLower()] += 1; - } - if (parts.size() == 1 && topLevelFiles.size() < 20) - { - topLevelFiles.append(file.relativePath); - } - } - - QVector> suffixPairs; - for (auto iterator = suffixCounts.cbegin(); iterator != suffixCounts.cend(); ++iterator) - { - suffixPairs.append({iterator.key(), iterator.value()}); - } - std::sort(suffixPairs.begin(), suffixPairs.end(), [](const auto &left, const auto &right) { - return left.second > right.second; - }); - - QStringList suffixLines; - const int suffixCount = qMin(suffixPairs.size(), 12); - for (int i = 0; i < suffixCount; ++i) - { - suffixLines.append(QStringLiteral(".%1: %2").arg(suffixPairs.at(i).first).arg(suffixPairs.at(i).second)); - } - - QStringList lines; - lines.append(QStringLiteral("工作区:%1").arg(m_session.workspaceRoot())); - lines.append(QStringLiteral("索引项:%1,目录:%2,文件:%3,文本文件:%4") - .arg(files.size()) - .arg(directoryCount) - .arg(fileCount) - .arg(textFileCount)); - lines.append(QStringLiteral("")); - lines.append(QStringLiteral("顶层目录:")); - lines.append(topLevelDirectories.isEmpty() ? QStringLiteral("- 无") : QStringLiteral("- ") + topLevelDirectories.join(QStringLiteral("\n- "))); - lines.append(QStringLiteral("")); - lines.append(QStringLiteral("顶层文件:")); - lines.append(topLevelFiles.isEmpty() ? QStringLiteral("- 无") : QStringLiteral("- ") + topLevelFiles.join(QStringLiteral("\n- "))); - lines.append(QStringLiteral("")); - lines.append(QStringLiteral("主要文件类型:")); - lines.append(suffixLines.isEmpty() ? QStringLiteral("- 无") : QStringLiteral("- ") + suffixLines.join(QStringLiteral("\n- "))); - - WorkspaceToolResult result; - result.success = true; - result.message = QStringLiteral("已生成项目结构摘要。"); - result.outputText = lines.join(QLatin1Char('\n')); - result.shouldRequestAiSummary = requestAiSummary; - result.aiFallbackText = result.outputText; - if (requestAiSummary) - { - result.aiPrompt = WorkspaceAgent::projectAnalysisPrompt(m_session.workspaceRoot(), result.outputText); - } - return result; -} - -QString WorkspaceController::extractSearchKeyword(const QString &message) const -{ - QString keyword = message.trimmed(); - static const QStringList markers = { - QStringLiteral("搜索"), - QStringLiteral("查找"), - QStringLiteral("找一下"), - }; - for (const QString &marker : markers) - { - const int index = keyword.indexOf(marker, 0, Qt::CaseInsensitive); - if (index >= 0) - { - keyword = keyword.mid(index + marker.size()); - break; - } - } - return removeNoiseWords(keyword); -} - -QString WorkspaceController::extractTextSearchKeyword(const QString &message) const -{ - const QString keyword = extractAfterFirstContentMarker(message).trimmed(); - if (!keyword.isEmpty()) - { - return keyword; - } - return extractSearchKeyword(message); -} - -QString WorkspaceController::extractReadTarget(const QString &message) const -{ - QString target = message.trimmed(); - if (isFileSummaryMessage(target)) - { - return removeFileAnalysisWords(target); - } - - static const QStringList markers = { - QStringLiteral("读取"), - QStringLiteral("读一下"), - QStringLiteral("阅读"), - QStringLiteral("总结"), - }; - for (const QString &marker : markers) - { - const int index = target.indexOf(marker, 0, Qt::CaseInsensitive); - if (index >= 0) - { - target = target.mid(index + marker.size()); - break; - } - } - return removeNoiseWords(target); -} - -QString WorkspaceController::extractOpenTarget(const QString &message) const -{ - QString target = message.trimmed(); - static const QStringList markers = { - QStringLiteral("打开"), - QStringLiteral("显示"), - }; - for (const QString &marker : markers) - { - const int index = target.indexOf(marker, 0, Qt::CaseInsensitive); - if (index >= 0) - { - target = target.mid(index + marker.size()); - break; - } - } - - static const QStringList noiseWords = { - QStringLiteral("所在位置"), - QStringLiteral("所在文件夹"), - QStringLiteral("位置"), - QStringLiteral("文件夹"), - QStringLiteral("目录"), - }; - for (const QString &word : noiseWords) - { - target.remove(word, Qt::CaseInsensitive); - } - return removeNoiseWords(target); -} - -int WorkspaceController::ordinalIndex(const QString &message) const -{ - const QString text = message.trimmed(); - static const QVector> ordinals = { - {QStringLiteral("第一个"), 0}, - {QStringLiteral("第一项"), 0}, - {QStringLiteral("第1个"), 0}, - {QStringLiteral("第1项"), 0}, - {QStringLiteral("第二个"), 1}, - {QStringLiteral("第二项"), 1}, - {QStringLiteral("第2个"), 1}, - {QStringLiteral("第2项"), 1}, - {QStringLiteral("第三个"), 2}, - {QStringLiteral("第三项"), 2}, - {QStringLiteral("第3个"), 2}, - {QStringLiteral("第3项"), 2}, - }; - for (const auto &ordinal : ordinals) - { - if (text.contains(ordinal.first)) - { - return ordinal.second; - } - } - return -1; -} - -bool WorkspaceController::isTextSearchMessage(const QString &message) const -{ - if (containsTextSearchPattern(message)) - { - return true; - } - - return containsAny(message, {QStringLiteral("包含"), QStringLiteral("全文"), QStringLiteral("文本")}) - && containsAny(message, {QStringLiteral("搜索"), QStringLiteral("查找"), QStringLiteral("找一下")}); -} - -bool WorkspaceController::isProjectSummaryMessage(const QString &message) const -{ - return containsAny(message, { - QStringLiteral("分析这个项目"), - QStringLiteral("分析当前项目"), - QStringLiteral("项目结构"), - QStringLiteral("工作区结构"), - QStringLiteral("总结这个项目"), - }); -} - -bool WorkspaceController::isFileSummaryMessage(const QString &message) const -{ - if (containsAny(message, { - QStringLiteral("总结文件"), - QStringLiteral("总结这个文件"), - QStringLiteral("总结第"), - QStringLiteral("分析文件"), - QStringLiteral("分析这个文件"), - QStringLiteral("分析第"), - QStringLiteral("解释文件"), - QStringLiteral("解释这个文件"), - QStringLiteral("解释第"), - QStringLiteral("解读文件"), - QStringLiteral("解读这个文件"), - QStringLiteral("解读第"), - QStringLiteral("看看文件"), - QStringLiteral("看看这个文件"), - QStringLiteral("看下这个文件"), - QStringLiteral("看一下这个文件"), - QStringLiteral("review文件"), - QStringLiteral("review这个文件"), - QStringLiteral("review 这个文件"), - QStringLiteral("review第"), - })) - { - return true; - } - - const bool hasFileTarget = containsAny(message, { - QStringLiteral("文件"), - QStringLiteral("README"), - QStringLiteral("CMakeLists"), - QStringLiteral(".cpp"), - QStringLiteral(".h"), - QStringLiteral(".hpp"), - QStringLiteral(".py"), - QStringLiteral(".js"), - QStringLiteral(".ts"), - QStringLiteral(".json"), - QStringLiteral(".cmake"), - QStringLiteral(".pro"), - }) || ordinalIndex(message) >= 0; - - return hasFileTarget && containsFileAnalysisVerb(message); -} - -bool WorkspaceController::isOpenMessage(const QString &message) const -{ - return message.contains(QStringLiteral("打开"), Qt::CaseInsensitive) - && !message.contains(QStringLiteral("打开工作区"), Qt::CaseInsensitive); -} - -bool WorkspaceController::isRevealMessage(const QString &message) const -{ - return containsAny(message, { - QStringLiteral("所在位置"), - QStringLiteral("所在文件夹"), - QStringLiteral("显示位置"), - QStringLiteral("显示文件位置"), - }); -} diff --git a/src/workspace/WorkspaceController.h b/src/workspace/WorkspaceController.h deleted file mode 100644 index 1573157..0000000 --- a/src/workspace/WorkspaceController.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include "WorkspaceScanner.h" -#include "WorkspaceSession.h" -#include "WorkspaceTypes.h" - -#include -#include - -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 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; -}; diff --git a/src/workspace/WorkspaceIndex.cpp b/src/workspace/WorkspaceIndex.cpp deleted file mode 100644 index a9de7e8..0000000 --- a/src/workspace/WorkspaceIndex.cpp +++ /dev/null @@ -1,137 +0,0 @@ -#include "WorkspaceIndex.h" - -#include - -#include - -void WorkspaceIndex::clear() -{ - m_files.clear(); -} - -void WorkspaceIndex::setFiles(const QVector &files) -{ - m_files = files; -} - -QVector WorkspaceIndex::allFiles() const -{ - return m_files; -} - -QVector WorkspaceIndex::findByName(const QString &keyword, int maxResults) const -{ - const QString normalizedKeyword = keyword.trimmed().toLower(); - if (normalizedKeyword.isEmpty() || maxResults <= 0) - { - return {}; - } - - QVector 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 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 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 WorkspaceIndex::recentFiles(int maxResults) const -{ - if (maxResults <= 0) - { - return {}; - } - - QVector 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(); -} diff --git a/src/workspace/WorkspaceIndex.h b/src/workspace/WorkspaceIndex.h deleted file mode 100644 index 7e4821c..0000000 --- a/src/workspace/WorkspaceIndex.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "WorkspaceTypes.h" - -#include -#include -#include - -class WorkspaceIndex -{ -public: - void clear(); - void setFiles(const QVector &files); - - QVector allFiles() const; - QVector findByName(const QString &keyword, int maxResults) const; - QVector findBySuffix(const QStringList &suffixes, int maxResults) const; - QVector recentFiles(int maxResults) const; - int size() const; - -private: - QVector m_files; -}; diff --git a/src/workspace/WorkspaceScanner.cpp b/src/workspace/WorkspaceScanner.cpp deleted file mode 100644 index 9947599..0000000 --- a/src/workspace/WorkspaceScanner.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "WorkspaceScanner.h" - -#include -#include -#include -#include - -namespace -{ -struct PendingDirectory -{ - QString path; - int depth = 0; -}; -} - -WorkspaceScanner::WorkspaceScanner(int maxDepth, int maxEntries) - : m_maxDepth(maxDepth) - , m_maxEntries(maxEntries) -{ -} - -QVector WorkspaceScanner::scan(const QString &workspaceRoot, QString *errorMessage) const -{ - if (!WorkspaceAccessPolicy::validateWorkspaceRoot(workspaceRoot, errorMessage)) - { - return {}; - } - - const WorkspaceAccessPolicy policy(workspaceRoot); - QVector files; - QVector 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; -} diff --git a/src/workspace/WorkspaceScanner.h b/src/workspace/WorkspaceScanner.h deleted file mode 100644 index f6a5ae1..0000000 --- a/src/workspace/WorkspaceScanner.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "WorkspaceAccessPolicy.h" -#include "WorkspaceTypes.h" - -#include -#include - -class WorkspaceScanner -{ -public: - explicit WorkspaceScanner(int maxDepth = 8, int maxEntries = 5000); - - QVector 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; -}; diff --git a/src/workspace/WorkspaceSession.cpp b/src/workspace/WorkspaceSession.cpp deleted file mode 100644 index a7cc597..0000000 --- a/src/workspace/WorkspaceSession.cpp +++ /dev/null @@ -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 &files) -{ - m_lastResults = files; -} - -QVector WorkspaceSession::lastResults() const -{ - return m_lastResults; -} - -void WorkspaceSession::setLastTextMatches(const QVector &matches) -{ - m_lastTextMatches = matches; -} - -QVector 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; -} diff --git a/src/workspace/WorkspaceSession.h b/src/workspace/WorkspaceSession.h deleted file mode 100644 index 77f0a6f..0000000 --- a/src/workspace/WorkspaceSession.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include "WorkspaceIndex.h" -#include "WorkspaceTypes.h" - -#include -#include -#include - -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 &files); - QVector lastResults() const; - - void setLastTextMatches(const QVector &matches); - QVector 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 m_lastResults; - QVector m_lastTextMatches; - WorkspaceFileInfo m_lastReadFile; - QString m_lastReadContent; - bool m_hasLastReadFile = false; -}; diff --git a/src/workspace/WorkspaceTool.h b/src/workspace/WorkspaceTool.h deleted file mode 100644 index e745a94..0000000 --- a/src/workspace/WorkspaceTool.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include "WorkspaceTypes.h" - -#include - -class WorkspaceTool -{ -public: - virtual ~WorkspaceTool() = default; - - virtual QString name() const = 0; - virtual QString displayName() const = 0; - virtual WorkspaceToolRisk risk() const = 0; -}; diff --git a/src/workspace/WorkspaceTypes.h b/src/workspace/WorkspaceTypes.h deleted file mode 100644 index 3076ae8..0000000 --- a/src/workspace/WorkspaceTypes.h +++ /dev/null @@ -1,97 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -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 files; - QVector textMatches; - bool shouldRequestAiSummary = false; - QString aiPrompt; - QString aiFallbackText; - bool hasActionPlan = false; - WorkspaceActionPlan actionPlan; -}; - -struct WorkspacePanelSnapshot -{ - QString workspaceRoot; - QDateTime lastScanAt; - int indexedItemCount = 0; - QVector lastResults; - QVector lastTextMatches; - bool hasLastReadFile = false; - WorkspaceFileInfo lastReadFile; - QString lastReadPreview; -}; diff --git a/src/workspace/tools/ListFilesTool.cpp b/src/workspace/tools/ListFilesTool.cpp deleted file mode 100644 index 9fb4d1f..0000000 --- a/src/workspace/tools/ListFilesTool.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include "ListFilesTool.h" - -#include -#include - -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; -} diff --git a/src/workspace/tools/ListFilesTool.h b/src/workspace/tools/ListFilesTool.h deleted file mode 100644 index 6a0f1ee..0000000 --- a/src/workspace/tools/ListFilesTool.h +++ /dev/null @@ -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; -}; diff --git a/src/workspace/tools/OpenDirectoryTool.cpp b/src/workspace/tools/OpenDirectoryTool.cpp deleted file mode 100644 index c15d71c..0000000 --- a/src/workspace/tools/OpenDirectoryTool.cpp +++ /dev/null @@ -1,56 +0,0 @@ -#include "OpenDirectoryTool.h" - -#include -#include -#include - -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; -} diff --git a/src/workspace/tools/OpenDirectoryTool.h b/src/workspace/tools/OpenDirectoryTool.h deleted file mode 100644 index a3b2593..0000000 --- a/src/workspace/tools/OpenDirectoryTool.h +++ /dev/null @@ -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; -}; diff --git a/src/workspace/tools/OpenFileTool.cpp b/src/workspace/tools/OpenFileTool.cpp deleted file mode 100644 index fdb3a6e..0000000 --- a/src/workspace/tools/OpenFileTool.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include "OpenFileTool.h" - -#include -#include -#include - -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; -} diff --git a/src/workspace/tools/OpenFileTool.h b/src/workspace/tools/OpenFileTool.h deleted file mode 100644 index 97baee0..0000000 --- a/src/workspace/tools/OpenFileTool.h +++ /dev/null @@ -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; -}; diff --git a/src/workspace/tools/ReadFileTool.cpp b/src/workspace/tools/ReadFileTool.cpp deleted file mode 100644 index 5174ff9..0000000 --- a/src/workspace/tools/ReadFileTool.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "ReadFileTool.h" - -#include -#include - -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; -} diff --git a/src/workspace/tools/ReadFileTool.h b/src/workspace/tools/ReadFileTool.h deleted file mode 100644 index 2d5b4bc..0000000 --- a/src/workspace/tools/ReadFileTool.h +++ /dev/null @@ -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; -}; diff --git a/src/workspace/tools/RevealInFileManagerTool.cpp b/src/workspace/tools/RevealInFileManagerTool.cpp deleted file mode 100644 index 1a41af7..0000000 --- a/src/workspace/tools/RevealInFileManagerTool.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "RevealInFileManagerTool.h" - -#include -#include -#include -#include - -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; -} diff --git a/src/workspace/tools/RevealInFileManagerTool.h b/src/workspace/tools/RevealInFileManagerTool.h deleted file mode 100644 index 8fe68bc..0000000 --- a/src/workspace/tools/RevealInFileManagerTool.h +++ /dev/null @@ -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; -}; diff --git a/src/workspace/tools/SearchFilesTool.cpp b/src/workspace/tools/SearchFilesTool.cpp deleted file mode 100644 index 5878a5d..0000000 --- a/src/workspace/tools/SearchFilesTool.cpp +++ /dev/null @@ -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; -} diff --git a/src/workspace/tools/SearchFilesTool.h b/src/workspace/tools/SearchFilesTool.h deleted file mode 100644 index 384ed21..0000000 --- a/src/workspace/tools/SearchFilesTool.h +++ /dev/null @@ -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; -}; diff --git a/src/workspace/tools/SearchTextTool.cpp b/src/workspace/tools/SearchTextTool.cpp deleted file mode 100644 index 12b4939..0000000 --- a/src/workspace/tools/SearchTextTool.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "SearchTextTool.h" - -#include -#include -#include - -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; -} diff --git a/src/workspace/tools/SearchTextTool.h b/src/workspace/tools/SearchTextTool.h deleted file mode 100644 index 2035345..0000000 --- a/src/workspace/tools/SearchTextTool.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "../WorkspaceAccessPolicy.h" -#include "../WorkspaceIndex.h" -#include "../WorkspaceTypes.h" - -class SearchTextTool -{ -public: - SearchTextTool(const WorkspaceAccessPolicy &policy, const WorkspaceIndex &index); - - WorkspaceToolResult run(const QString &keyword, int maxFilesToScan = 1000, int maxMatches = 50) const; - -private: - const WorkspaceAccessPolicy &m_policy; - const WorkspaceIndex &m_index; -}; diff --git a/tools/prepare_github_export.ps1 b/tools/prepare_github_export.ps1 index 01c19d9..e406cd1 100644 --- a/tools/prepare_github_export.ps1 +++ b/tools/prepare_github_export.ps1 @@ -40,6 +40,7 @@ function Should-ExcludeDirectory([string]$RelativePath) { "dist", "release_packages", "docs", + "internal", "reports", "config", "logs" @@ -134,6 +135,15 @@ if (Test-Path -LiteralPath $outputPath) { New-Item -ItemType Directory -Path $outputPath -Force | Out-Null Copy-PublicTree -SourceDir $repoRoot -DestinationDir $outputPath -RelativeDir "" +foreach ($forbiddenRoot in @("docs", "internal", "reports")) { + if (Test-Path -LiteralPath (Join-Path $outputPath $forbiddenRoot)) { + throw "Public export isolation failed; forbidden directory was copied: $forbiddenRoot" + } +} +if (Test-Path -LiteralPath (Join-Path $outputPath "README.dev.md")) { + throw "Public export isolation failed; README.dev.md was copied." +} + Write-Host "GitHub export prepared:" Write-Host " $outputPath" Write-Host ""