diff --git a/CMakeLists.txt b/CMakeLists.txt index 886bb0c..194af61 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,8 @@ 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 @@ -96,6 +98,10 @@ 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 @@ -112,6 +118,34 @@ 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 85d8832..533870d 100644 --- a/README.dev.md +++ b/README.dev.md @@ -321,6 +321,43 @@ QStandardPaths::AppDataLocation/sounds/reminders/ - 修改源码 - 执行脚本或命令 +## 本地工作区 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 +``` + ## 联网模式 联网模式不是搜索引擎聚合器。用户在输入框打开“联网”开关后,普通聊天会进入当前 AI Provider 的原生联网能力: @@ -329,6 +366,7 @@ QStandardPaths::AppDataLocation/sounds/reminders/ - Google Gemini Provider:使用 Gemini Google Search grounding。 - DeepSeek 官方 API:当前不提供托管联网搜索工具,显示“不支持原生联网”。 - Custom / 第三方 OpenAI-Compatible:默认无法确认联网能力,不会尝试旧搜索页抓取。 +- 输入框“联网”开关在启用“记住输入框联网开关状态”时会随用户切换立即保存,不依赖发送消息。 配置保存到: diff --git a/README.md b/README.md index 80d22d2..3ff2f24 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ - 列出文件夹 - 复制、备份、重命名 - 写操作前二次确认 +- 本地工作区 Agent v1b: + - 用户选择工作区后扫描并建立内存索引 + - 搜索文件名、搜索文本、读取工作区文本文件 + - 项目结构摘要和文件内容总结 + - 独立 Agent 窗口展示文件结果、文本命中、上下文、任务对话和详情预览 + - 支持右键菜单、托盘菜单和默认 `Alt+A` 呼出窗口 + - 打开工作区内普通文件、打开文件夹、显示文件所在位置 + - 不执行命令,不创建/修改/移动/删除文件 - 应用启动 v1: - 聊天触发打开本地应用 - 支持已登记应用、开始菜单快捷方式和用户手选 `.exe` @@ -85,7 +93,8 @@ │ ├── ui/ # Widgets and main pet window │ ├── util/ │ ├── weather/ -│ └── web/ # AI-native web mode +│ ├── web/ # AI-native web mode +│ └── workspace/ # Read-only local workspace agent └── tools/ # Packaging and diagnostic scripts ``` diff --git a/docs/QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md b/docs/QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md new file mode 100644 index 0000000..8e898fb --- /dev/null +++ b/docs/QtDesktopPet 本地工作区 Agent 独立窗口化施工文档.md @@ -0,0 +1,1230 @@ +# QtDesktopPet 本地工作区 Agent 独立窗口化施工文档 + +## 0. 本次施工目标 + +当前项目已经有“本地工作区 Agent”面板雏形,能够显示: + +- 当前工作区路径 +- 索引项数量 +- 最近扫描时间 +- 上一轮搜索结果 +- 选中文件详情 +- 文本命中 +- 最近读取 +- 读取 / 打开 / 定位 等操作按钮 + +但当前形态更接近“文件搜索面板”,还不是类似 Codex / Claude Code 的工作区 Agent 面板。 + +本次施工目标: + +> 将本地工作区 Agent 从桌宠窗口中独立出来,做成一个单独的非模态主窗口,并改造成“左侧工作区导航 + 中间 Agent 对话 + 右侧上下文 / 文件详情 / 变更确认 + 底部任务输入框”的 Codex 风格面板。 + +------ + +# 1. 产品定位 + +## 1.1 桌宠窗口职责 + +桌宠本体只负责: + +```text +1. 桌宠动画显示 +2. 气泡提示 +3. 托盘控制 +4. 打开设置窗口 +5. 打开本地工作区 Agent 窗口 +6. 显示任务状态,例如“正在分析”“等待确认”“修改完成” +``` + +桌宠窗口不应该承载: + +```text +1. 文件树 +2. 搜索结果列表 +3. 多轮 Agent 对话 +4. 文件内容预览 +5. Diff 预览 +6. 修改计划确认 +7. 复杂任务日志 +``` + +------ + +## 1.2 本地工作区 Agent 窗口职责 + +本地工作区 Agent 独立窗口负责: + +```text +1. 选择 / 切换工作区 +2. 扫描并索引工作区 +3. 显示文件树 / 搜索结果 / 上下文文件 +4. 支持用户输入任务 +5. 显示 Agent 对话过程 +6. 显示工具调用过程 +7. 显示已读取文件 +8. 显示待确认操作 +9. 显示 Diff 或修改计划 +10. 执行确认后的文件操作 +``` + +------ + +# 2. 为什么必须做成独立窗口 + +## 2.1 当前问题 + +如果本地工作区 Agent 和桌宠共用一个窗口,会导致: + +```text +1. PetWindow 职责继续膨胀 +2. 桌宠动画逻辑和工作区 UI 强耦合 +3. UI 尺寸冲突,桌宠本体不适合承载复杂面板 +4. 复杂任务状态难以展示 +5. 后续 Diff、文件树、搜索、日志、对话区很难布局 +6. 用户无法像使用 IDE / Codex 一样长时间停留在 Agent 面板中 +``` + +------ + +## 2.2 独立窗口优势 + +独立窗口可以获得: + +```text +1. 更像 Codex / Claude Code 的工作流 +2. 可最大化 / 最小化 / 单独移动 +3. 不影响桌宠动画 +4. 可以保存窗口大小和位置 +5. 可以长期展示复杂任务 +6. 可以后续加入 Diff 面板、文件树、任务日志 +7. PetWindow 只作为入口,整体架构更干净 +``` + +------ + +# 3. 总体架构 + +新的调用关系应为: + +```text +PetWindow + ↓ +openWorkspaceAgentWindow() + ↓ +WorkspaceAgentWindow + ↓ +WorkspacePanel + ↓ +WorkspaceController + ↓ +WorkspaceAgent + ↓ +WorkspaceToolRegistry + ↓ +Workspace Tools +``` + +其中: + +```text +PetWindow: + 只负责打开窗口和接收状态提示。 + +WorkspaceAgentWindow: + 独立顶层窗口,负责窗口行为。 + +WorkspacePanel: + 主 UI 容器,负责布局。 + +WorkspaceController: + UI 和业务逻辑之间的桥梁。 + +WorkspaceAgent: + 处理用户任务、文件分析、工具调用计划。 + +WorkspaceToolRegistry: + 管理文件工具。 + +Workspace Tools: + 读取、搜索、打开、创建、移动、删除到回收站等具体工具。 +``` + +------ + +# 4. 新增 UI 目录结构 + +建议新增: + +```text +src/ui/workspace/ +├── WorkspaceAgentWindow.h +├── WorkspaceAgentWindow.cpp +├── WorkspacePanel.h +├── WorkspacePanel.cpp +├── WorkspaceTopBar.h +├── WorkspaceTopBar.cpp +├── WorkspaceSidebar.h +├── WorkspaceSidebar.cpp +├── WorkspaceChatView.h +├── WorkspaceChatView.cpp +├── WorkspaceInspector.h +├── WorkspaceInspector.cpp +├── WorkspaceInputBar.h +├── WorkspaceInputBar.cpp +├── WorkspacePlanReviewWidget.h +├── WorkspacePlanReviewWidget.cpp +├── WorkspaceDiffView.h +└── WorkspaceDiffView.cpp +``` + +第一阶段可以先实现: + +```text +WorkspaceAgentWindow +WorkspacePanel +WorkspaceTopBar +WorkspaceSidebar +WorkspaceChatView +WorkspaceInspector +WorkspaceInputBar +``` + +`WorkspacePlanReviewWidget` 和 `WorkspaceDiffView` 可以先放空壳,后续接写操作确认。 + +------ + +# 5. WorkspaceAgentWindow 设计 + +## 5.1 类型选择 + +建议使用: + +```cpp +class WorkspaceAgentWindow : public QWidget +``` + +或者: + +```cpp +class WorkspaceAgentWindow : public QMainWindow +``` + +推荐第一版使用 `QWidget`,因为当前项目 UI 结构大多基于 QWidget,迁移成本低。 + +窗口必须是独立顶层窗口,不要作为 PetWindow 的子控件。 + +创建时: + +```cpp +m_workspaceAgentWindow = std::make_unique(); +m_workspaceAgentWindow->show(); +m_workspaceAgentWindow->raise(); +m_workspaceAgentWindow->activateWindow(); +``` + +不要传 `this` 作为 parent。 + +------ + +## 5.2 窗口行为 + +建议窗口属性: + +```cpp +setWindowTitle(QStringLiteral("本地工作区 Agent")); +resize(1180, 760); +setMinimumSize(960, 600); +setWindowFlags(Qt::Window); +``` + +不要使用: + +```cpp +Qt::Tool +``` + +原因: + +`Qt::Tool` 更像附属工具窗,可能不显示在任务栏,不适合 Codex 风格主面板。 + +------ + +## 5.3 关闭行为 + +建议关闭窗口时只隐藏,不销毁。 + +实现思路: + +```cpp +void WorkspaceAgentWindow::closeEvent(QCloseEvent *event) +{ + event->ignore(); + hide(); +} +``` + +这样: + +```text +1. 工作区上下文不会丢失 +2. 搜索结果不会丢失 +3. Agent 对话不会丢失 +4. 再次打开速度更快 +``` + +退出程序时由主程序统一析构。 + +------ + +# 6. PetWindow 接入方案 + +## 6.1 新增成员 + +在 `PetWindow.h` 中增加前置声明: + +```cpp +class WorkspaceAgentWindow; +``` + +新增成员: + +```cpp +std::unique_ptr m_workspaceAgentWindow; +``` + +------ + +## 6.2 新增方法 + +在 `PetWindow` 中新增: + +```cpp +void openWorkspaceAgentWindow(); +``` + +实现逻辑: + +```cpp +void PetWindow::openWorkspaceAgentWindow() +{ + if (!m_workspaceAgentWindow) + { + m_workspaceAgentWindow = std::make_unique(); + } + + m_workspaceAgentWindow->show(); + m_workspaceAgentWindow->raise(); + m_workspaceAgentWindow->activateWindow(); +} +``` + +注意: + +```text +WorkspaceAgentWindow 不要设置 PetWindow 为 parent。 +``` + +------ + +## 6.3 菜单入口 + +在桌宠右键菜单增加: + +```text +打开本地工作区 Agent +``` + +点击后调用: + +```cpp +openWorkspaceAgentWindow(); +``` + +后续也可以在聊天中识别: + +```text +打开工作区 +打开 Agent +打开本地工作区 +``` + +然后调用该窗口。 + +------ + +# 7. WorkspacePanel 布局设计 + +## 7.1 总体布局 + +目标布局: + +```text +┌─────────────────────────────────────────────────────────────┐ +│ TopBar:工作区路径 / 索引状态 / 模式 / 按钮 │ +├──────────────┬────────────────────────────┬─────────────────┤ +│ Sidebar │ ChatView │ Inspector │ +│ 文件树/搜索 │ Agent 对话 / 工具调用过程 │ 详情/命中/变更 │ +├──────────────┴────────────────────────────┴─────────────────┤ +│ InputBar:任务输入框 / 发送 / 停止 / 附加文件 / 模式切换 │ +└─────────────────────────────────────────────────────────────┘ +``` + +------ + +## 7.2 推荐 Qt 布局 + +使用: + +```cpp +QVBoxLayout *rootLayout; +QHBoxLayout *bodyLayout; +``` + +结构: + +```cpp +rootLayout + ├── WorkspaceTopBar + ├── bodyLayout + │ ├── WorkspaceSidebar + │ ├── WorkspaceChatView + │ └── WorkspaceInspector + └── WorkspaceInputBar +``` + +建议宽度比例: + +```text +Sidebar: 280 px +ChatView: stretch 1 +Inspector: 320 px +``` + +可以使用: + +```cpp +bodyLayout->setStretch(0, 0); +bodyLayout->setStretch(1, 1); +bodyLayout->setStretch(2, 0); +``` + +------ + +# 8. 顶部栏 WorkspaceTopBar + +## 8.1 显示内容 + +顶部栏显示: + +```text +本地工作区 Agent + +工作区:D:/programming/成品项目/项目/Qt_DesktopPet [切换工作区] +索引项:389 最近扫描:2026-06-23 09:49:02 [重新索引] +模式:只读 / 确认写入 [设置] +``` + +------ + +## 8.2 按钮 + +需要以下按钮: + +```text +选择工作区 +重新索引 +清空上下文 +停止任务 +权限模式 +``` + +第一版按钮可以先实现: + +```text +选择工作区 +重新索引 +关闭 +``` + +------ + +# 9. 左侧栏 WorkspaceSidebar + +## 9.1 Tab 结构 + +左侧栏建议使用 `QTabWidget`: + +```text +文件 +搜索 +上下文 +``` + +------ + +## 9.2 文件 Tab + +显示文件树。 + +第一版可以先不用完整树形结构,继续使用当前已有的索引结果列表。 + +后续再升级为: + +```cpp +QTreeView + QFileSystemModel +``` + +或自定义模型。 + +按钮: + +```text +读取 +打开 +定位 +加入上下文 +复制路径 +``` + +------ + +## 9.3 搜索 Tab + +包含: + +```text +搜索输入框 +搜索类型:文件名 / 文本内容 +搜索按钮 +结果列表 +``` + +搜索结果点击后: + +```text +1. 更新右侧 Inspector +2. 可双击读取 +3. 可加入上下文 +``` + +------ + +## 9.4 上下文 Tab + +显示 Agent 当前已经读取或参考过的文件: + +```text +已加入上下文: +- README.md +- CMakeLists.txt +- src/workspace/WorkspaceController.cpp +``` + +每项支持: + +```text +移出上下文 +打开 +定位 +重新读取 +``` + +------ + +# 10. 中间区 WorkspaceChatView + +## 10.1 职责 + +中间区是 Agent 面板核心。 + +它负责显示: + +```text +1. 用户输入 +2. Agent 回复 +3. 工具调用 +4. 工具返回结果 +5. 文件读取记录 +6. 搜索结果摘要 +7. 修改计划 +8. 错误提示 +``` + +------ + +## 10.2 消息类型 + +新增消息类型: + +```cpp +enum class WorkspaceMessageType +{ + User, + Assistant, + ToolCall, + ToolResult, + FileRead, + SearchResult, + Plan, + Diff, + Warning, + Error +}; +``` + +消息结构: + +```cpp +struct WorkspaceMessage +{ + WorkspaceMessageType type = WorkspaceMessageType::Assistant; + QString title; + QString content; + QStringList relatedFiles; + QDateTime createdAt; +}; +``` + +------ + +## 10.3 显示样式 + +第一版可以用: + +```cpp +QTextEdit +``` + +或者: + +```cpp +QScrollArea + QVBoxLayout + 自定义 message widget +``` + +推荐第一版用 `QScrollArea + QVBoxLayout`。 + +原因: + +```text +1. 后续每种消息可以有不同样式 +2. 工具调用可以做折叠 +3. Diff 可以单独嵌入控件 +4. 比 QTextEdit 更适合 Agent UI +``` + +------ + +# 11. 右侧栏 WorkspaceInspector + +## 11.1 Tab 结构 + +右侧栏建议使用 `QTabWidget`: + +```text +详情 +命中 +变更 +``` + +------ + +## 11.2 详情 Tab + +显示当前选中文件: + +```text +src/workspace/WorkspaceAccessPolicy.h + +类型:文本文件 +大小:1434 字节 +修改时间:2026-06-23 04:59:27 +相对路径:src/workspace/WorkspaceAccessPolicy.h + +[读取] [打开] [定位] [加入上下文] +``` + +------ + +## 11.3 命中 Tab + +显示文本搜索命中: + +```text +README.dev.md:333 +搜索包含 class 的文件:搜索文本内容,显示路径、行号和片段 + +src/workspace/WorkspaceAccessPolicy.h:5 +class WorkspaceAccessPolicy +``` + +------ + +## 11.4 变更 Tab + +用于后续显示待确认修改: + +```text +待确认变更: + +1. src/ui/workspace/WorkspacePanel.cpp + - 调整为三栏布局 + +2. src/ui/workspace/WorkspaceInputBar.cpp + - 新增任务输入栏 + +[查看 Diff] [确认应用] [取消] +``` + +第一版可以先显示: + +```text +暂无待确认变更 +``` + +------ + +# 12. 底部输入栏 WorkspaceInputBar + +## 12.1 基本控件 + +底部输入栏包含: + +```text +任务输入框 +发送按钮 +停止按钮 +附加文件按钮 +模式按钮 +``` + +第一版可以只做: + +```text +任务输入框 +发送按钮 +停止按钮 +``` + +输入框 placeholder: + +```text +输入任务,例如:分析这个项目结构,搜索 workspace 相关代码,读取第二个文件…… +``` + +------ + +## 12.2 快捷命令 + +后续支持: + +```text +/scan 重新扫描工作区 +/search 搜索文件或文本 +/read 读取文件 +/clear 清空上下文 +/mode 切换权限模式 +``` + +第一版不强制实现。 + +------ + +# 13. 当前面板迁移方案 + +当前截图中的内容迁移如下: + +## 13.1 当前“上一轮结果” + +迁移到: + +```text +WorkspaceSidebar -> 搜索 Tab +``` + +------ + +## 13.2 当前“选中项” + +迁移到: + +```text +WorkspaceInspector -> 详情 Tab +``` + +------ + +## 13.3 当前“文本命中” + +迁移到: + +```text +WorkspaceInspector -> 命中 Tab +``` + +------ + +## 13.4 当前“最近读取” + +迁移到: + +```text +WorkspaceSidebar -> 上下文 Tab +``` + +或者: + +```text +WorkspaceInspector -> 详情 Tab 下方 +``` + +推荐放到左侧“上下文 Tab”。 + +------ + +## 13.5 当前底部按钮 + +当前按钮: + +```text +读取 +打开 +位置 +关闭 +``` + +迁移为: + +```text +详情 Tab: + 读取 + 打开 + 定位 + 加入上下文 + +TopBar: + 关闭 + +InputBar: + 发送 + 停止 +``` + +------ + +# 14. 样式建议 + +当前面板深色风格可以保留。 + +建议统一颜色变量: + +```cpp +const QString BackgroundColor = "#0f172a"; +const QString PanelColor = "#17233a"; +const QString BorderColor = "#2b4164"; +const QString PrimaryColor = "#2d5be3"; +const QString TextColor = "#ffffff"; +const QString MutedTextColor = "#b9c7dd"; +``` + +不要在每个控件里到处写重复样式。 + +建议新建: + +```text +src/ui/workspace/WorkspaceStyle.h +``` + +或在 `WorkspacePanel.cpp` 内先集中管理。 + +------ + +# 15. WorkspaceController 对接 + +UI 不直接调用底层文件工具。 + +UI 调用: + +```cpp +WorkspaceController +``` + +建议接口: + +```cpp +class WorkspaceController +{ +public: + bool hasWorkspace() const; + QString workspaceRoot() const; + + bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr); + bool rescan(QString *errorMessage = nullptr); + + QVector indexedFiles() const; + QVector lastSearchResults() const; + QVector lastTextMatches() const; + QVector recentlyReadFiles() const; + + WorkspaceToolResult searchFiles(const QString &keyword); + WorkspaceToolResult searchText(const QString &keyword); + WorkspaceToolResult readFile(const QString &relativeOrAbsolutePath); + WorkspaceToolResult openFile(const QString &relativeOrAbsolutePath); + WorkspaceToolResult revealFile(const QString &relativeOrAbsolutePath); + + WorkspaceToolResult handleUserTask(const QString &message); +}; +``` + +第一版 `handleUserTask` 可以先做规则分发,不必实现完整模型工具调用。 + +------ + +# 16. Agent 对话第一版行为 + +第一版不要急着做完整自动 Agent。 + +先做规则驱动: + +```text +用户输入包含“搜索”: + 调 searchFiles 或 searchText + +用户输入包含“读取 / 打开第几个 / 第二个”: + 使用上一轮搜索结果 + +用户输入包含“分析项目”: + 读取 README.md、CMakeLists.txt、main.cpp、src 目录结构,然后交给 AI + +用户输入普通问题: + 作为普通 AI 对话,但附加当前工作区摘要 +``` + +------ + +# 17. 确认机制 + +涉及写操作时,必须进入确认流程。 + +窗口中显示: + +```text +Agent 准备执行以下操作: + +操作:移动文件 +源路径:src/old/FileOperationManager.cpp +目标路径:src/workspace/old/FileOperationManager.cpp +风险:高 + +是否确认? +``` + +按钮: + +```text +确认执行 +取消 +``` + +第一版可以先只展示计划,不真正实现写操作。 + +------ + +# 18. 施工顺序 + +## 阶段 1:独立窗口 + +目标: + +```text +新增 WorkspaceAgentWindow +新增 WorkspacePanel +PetWindow 右键菜单可以打开独立窗口 +窗口可以独立移动、最小化、最大化 +关闭窗口时隐藏,不销毁 +``` + +验收: + +```text +桌宠窗口和 Agent 窗口彼此独立 +关闭 Agent 窗口不退出程序 +再次打开保留状态 +``` + +------ + +## 阶段 2:迁移现有面板 + +目标: + +```text +把当前已有的工作区路径、索引项、搜索结果、选中项、文本命中、最近读取迁移到 WorkspacePanel +``` + +验收: + +```text +新窗口中能看到当前截图已有功能 +读取 / 打开 / 定位功能正常 +``` + +------ + +## 阶段 3:三栏布局 + +目标: + +```text +顶部 TopBar +左侧 Sidebar +中间 ChatView +右侧 Inspector +底部 InputBar +``` + +验收: + +```text +界面从“文件检索器”变成“Agent 工作台” +中间区域为对话主区域 +左侧和右侧为辅助区域 +``` + +------ + +## 阶段 4:任务输入栏 + +目标: + +```text +底部可以输入任务 +点击发送后添加用户消息 +系统根据规则返回 Agent 消息 +``` + +验收: + +```text +用户输入“搜索 workspace” +左侧显示搜索结果 +中间显示工具调用过程 +右侧显示选中文件详情 +``` + +------ + +## 阶段 5:工作区选择 + +目标: + +```text +顶部增加“选择工作区” +使用 QFileDialog 选择目录 +选择后扫描索引 +更新 TopBar 状态 +``` + +验收: + +```text +可以切换工作区 +切换后搜索结果清空 +索引数量更新 +最近扫描时间更新 +``` + +------ + +## 阶段 6:Agent 对话只读分析 + +目标: + +```text +支持“分析这个项目” +自动读取 README.md、CMakeLists.txt、main.cpp 和 src 目录结构 +调用现有 AI 对话能力返回分析结果 +``` + +验收: + +```text +用户可以在面板中输入“分析这个项目” +系统能显示读取了哪些文件 +AI 能给出项目结构分析 +``` + +------ + +## 阶段 7:确认计划与变更区 + +目标: + +```text +右侧增加“变更”Tab +支持展示待确认操作计划 +``` + +验收: + +```text +写操作不会直接执行 +会先显示计划 +用户确认后才执行 +``` + +------ + +# 19. CMake 修改 + +新增文件后,需要加入 `CMakeLists.txt`。 + +第一阶段至少加入: + +```text +src/ui/workspace/WorkspaceAgentWindow.h +src/ui/workspace/WorkspaceAgentWindow.cpp +src/ui/workspace/WorkspacePanel.h +src/ui/workspace/WorkspacePanel.cpp +src/ui/workspace/WorkspaceTopBar.h +src/ui/workspace/WorkspaceTopBar.cpp +src/ui/workspace/WorkspaceSidebar.h +src/ui/workspace/WorkspaceSidebar.cpp +src/ui/workspace/WorkspaceChatView.h +src/ui/workspace/WorkspaceChatView.cpp +src/ui/workspace/WorkspaceInspector.h +src/ui/workspace/WorkspaceInspector.cpp +src/ui/workspace/WorkspaceInputBar.h +src/ui/workspace/WorkspaceInputBar.cpp +``` + +注意: + +当前项目 `CMAKE_AUTOMOC` 是关闭的。 + +所以第一版新增 UI 类尽量不要使用 `Q_OBJECT`。 + +需要回调时优先使用: + +```cpp +std::function +``` + +例如: + +```cpp +void setSendCallback(std::function callback); +``` + +------ + +# 20. 编码要求 + +## 20.1 大括号风格 + +使用 Allman 风格: + +```cpp +if (condition) +{ + doSomething(); +} +``` + +------ + +## 20.2 不使用 OutputDebugStringA + +不要使用: + +```cpp +OutputDebugStringA +``` + +日志使用项目已有 Logger,或者在必要时使用控制台输出。 + +------ + +## 20.3 不引入复杂依赖 + +第一版不要引入: + +```text +QML +WebView +第三方 Diff 库 +SQLite +向量数据库 +Everything SDK +``` + +先用 Qt Widgets 原生控件完成闭环。 + +------ + +# 21. 验收清单 + +完成后必须满足: + +```text +1. Agent 面板是独立顶层窗口。 +2. 桌宠窗口关闭 / 隐藏不影响 Agent 面板。 +3. Agent 面板关闭时默认隐藏,不丢失状态。 +4. 可从桌宠右键菜单打开 Agent 面板。 +5. Agent 面板有顶部工作区栏。 +6. Agent 面板有左侧文件 / 搜索 / 上下文区域。 +7. Agent 面板有中间对话区。 +8. Agent 面板有右侧详情 / 命中 / 变更区域。 +9. Agent 面板有底部任务输入栏。 +10. 可以选择工作区。 +11. 可以重新索引。 +12. 可以搜索文件。 +13. 可以读取文件。 +14. 可以打开文件。 +15. 可以定位文件。 +16. 可以显示最近读取。 +17. 可以输入任务并在中间对话区显示用户消息和 Agent 回复。 +18. 不发生 PetWindow 继续膨胀的问题。 +19. 项目可以正常编译。 +``` + +------ + +# 22. 最终目标 + +本次改造完成后,界面形态应从: + +```text +文件检索面板 +``` + +升级为: + +```text +Codex 风格的本地工作区 Agent 面板 +``` + +桌宠负责陪伴和入口,Agent 窗口负责复杂任务。 + +最终产品体验应类似: + +```text +用户打开桌宠 + ↓ +点击“本地工作区 Agent” + ↓ +弹出独立工作区窗口 + ↓ +选择项目目录 + ↓ +输入“分析这个项目” + ↓ +Agent 搜索、读取、分析文件 + ↓ +在对话区展示过程和结论 + ↓ +需要修改时展示计划和 Diff + ↓ +用户确认后执行 +``` + +这是后续接近 Codex / Claude Code 工作流的基础。 \ No newline at end of file diff --git a/docs/QtDesktopPet_后续功能规划与结构审查.md b/docs/QtDesktopPet_后续功能规划与结构审查.md index c2999e1..89c85e9 100644 --- a/docs/QtDesktopPet_后续功能规划与结构审查.md +++ b/docs/QtDesktopPet_后续功能规划与结构审查.md @@ -1,1214 +1,104 @@ -# QtDesktopPet 后续功能开发与当前结构审查文档 +# QtDesktopPet 当前路线与结构收口 -> 适用仓库:`https://git.emoera.com/Make/Qt_DesktopPet` -> 目的:在继续新增功能前,先收口当前结构风险,并为后续功能扩展确定统一入口和实现边界。 -> 注意:本文档不是让 Codex 一次性实现所有功能,而是作为后续开发路线和代码审查依据。 +本文档是当前有效的开发路线总览。旧版长文档已归档到 `docs/archive/QtDesktopPet_后续功能规划与结构审查_旧版.md`。 ---- +## 当前产品状态 -## 1. 当前项目状态概览 +项目当前是一个 Windows 优先的 Qt 6 Widgets 桌面宠物应用,主要能力包括: -根据当前 README 和仓库结构,项目已经具备较完整的桌宠应用基础: +- 透明无边框桌宠窗口、拖动、置顶、托盘和单实例唤醒。 +- PNG 序列帧角色包、多状态动画、角色导入、切换、导出和用户角色目录管理。 +- AI 对话,支持 OpenAI Compatible、OpenAI 官方、Google Gemini、DeepSeek、Custom 配置。 +- 输入框联网模式,使用 AI Provider 原生联网能力,不再维护旧搜索引擎聚合。 +- 定时提醒,支持一次性和每天/每周/每月重复提醒、音效管理、历史保留和隐藏/AI 忙通知规则。 +- 天气查询,使用 Open-Meteo、默认城市、IP fallback、多候选提示和默认城市测试。 +- 对话历史管理,支持本地保存、关键词搜索、Provider/模型/时间筛选、Markdown/JSON 导出。 +- 本地应用启动,支持登记应用、别名、手选 `.exe`、启动前确认和开机自启动配置。 +- 本地文件操作 v1,支持读取文本、列目录、复制、备份和重命名。 +- 本地工作区 Agent v1b,支持选择工作区、扫描索引、搜索文件、搜索文本、读取文本文件、项目结构摘要、文件内容总结、打开普通文件、显示所在位置和独立 Agent 窗口。 -- 透明无边框桌宠窗口 -- 鼠标拖动、置顶、右键菜单 -- 托盘显示、隐藏、退出 -- 单实例限制 -- PNG 序列帧动画播放 -- `idle / talk / think / sleep / happy / drag / error` 多状态动画 -- 隐藏时暂停动画,显示时恢复 -- 窗口位置、置顶、缩放、性能设置保存 -- 文件日志和日志轮转 -- AI Provider 分组配置 -- OpenAI Compatible 聊天请求 -- Google Gemini 原生聊天请求 -- SSE 流式输出 -- AI 回复气泡 -- 聊天输入框 -- 对话历史面板 -- 本地历史保存和历史上限 -- AI 请求取消和对话清空 -- 角色文件夹导入和角色切换 -- 删除用户导入角色 -- 本地一次性/重复提醒、提醒列表、取消提醒和到点通知 -- 内置/用户提醒音效切换、导入、删除和试听 -- 天气查询、默认城市、公网 IP 定位兜底和多候选提示 -- 本地文件操作安全入口:读取文本、列目录、复制、备份、重命名 -- 联网模式:输入框开关、OpenAI/Gemini 原生联网、DeepSeek/Custom 不支持提示 -- 本地应用启动:登记应用、开始菜单 / App Paths 发现、手选 `.exe` 和二次确认 -- Windows 打包脚本和 Inno Setup 安装器脚本 -- Release exe 双击不弹控制台窗口 +## 当前结构边界 -项目已经从早期 MVP 进入到“可扩展桌面应用原型”阶段,可以开始规划工具能力扩展。 +已经形成的模块边界: -但是,在正式加入定时提醒、天气、本地文件操作、联网模式之前,建议先做一轮结构收口。 +| 模块 | 目录 | 状态 | +| --- | --- | --- | +| 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 独立窗口化已落地 | ---- +`PetWindow` 仍然承担较多 UI 编排职责。后续新增大功能必须优先放在独立模块和 Controller 中,`PetWindow` 只保留入口、展示和确认交互。 -## 2. 当前需要优先审查或优化的地方 +## 下一阶段主线 -### 2.1 审查角色切换是否保存后立即生效 +下一阶段主线仍是本地工作区 Agent,而不是继续扩展旧 `src/fileops/`。 -当前已经支持角色导入和角色切换。经当前测试确认: +v1a.1/v1b/v1b.5 已完成: -```text -设置页选择新角色 -保存设置 -当前桌宠窗口是否立即重新加载新角色包 -``` +- 用户选择一个本地工作区目录。 +- 桌宠可以扫描、搜索、读取和分析该工作区内文件。 +- 桌宠可以打开工作区内普通文件、打开文件夹、显示文件所在位置。 +- 独立 Agent 窗口可以展示文件结果、文本命中、最近读取、用户任务、本地工具结果和 AI 分析结果,并对选中结果执行读取、打开、显示位置。 +- 写操作误分发已收口:创建/新建/写入/修改不会再误走旧 fileops 的读取文本文件流程。 +- 文本搜索误分发已收口:`含class的文件 / 包含 class 的文件 / 找一下含 class 的文件` 会走工作区全文搜索,不再弹旧文本文件选择框。 -结论:保存后会立即切换,不依赖重启。 +后续目标: -当前代码路径: +- 桌宠可以生成文件修改计划。 +- 创建、修改、复制、移动、重命名、删除到回收站等写操作必须先展示计划并等待用户确认。 +- 不允许访问工作区外路径。 +- 不允许执行系统命令、脚本或聊天文本中的命令参数。 +- 不允许永久删除文件。 -```text -PetWindow::openSettingsDialog() -↓ -applyAppConfig(dialog.appConfig()) -↓ -PetWindow::applyAppConfig() 比较旧 characterId 与新 characterId -↓ -characterId 变化时停止当前动画并调用 loadCharacterPackage() -↓ -loadCharacterPackage() 重新构建动画状态并从 idle 状态开始播放 -``` +正式施工计划见: -后续不需要围绕 2.1 做修复,只需要保留为回归检查项: +`docs/agent/DesktopPet_本地工作区Agent_施工计划.md` -```text -设置页切换角色后点击保存,桌宠应立即显示新角色 -不需要关闭或重启程序 -切换后动画状态应可继续正常播放 -``` +## 功能优先级建议 ---- +当前聊天意图建议保持以下原则: -### 2.2 不要继续向 PetWindow 堆功能 +1. 提醒明确优先,例如“明天提醒我看天气”应创建提醒。 +2. 天气明确问答走天气模块,例如“明天西安天气怎么样”。 +3. 工作区相关文件和代码任务走 Workspace Agent,例如“搜索这个项目里的 README”“读取 main.cpp”“分析这个项目”;重命名等写操作留到后续阶段。 +4. 明确应用启动走 Launcher,例如“打开 Codex”“启动酷狗音乐”。 +5. 其余走普通聊天或联网聊天。 -目前 `PetWindow` 已经承担了过多职责,后续不能继续把所有新功能塞进去。 +不能简单用“文件 / 打开 / 搜索”这类宽关键词吞掉所有请求。尤其要避免: -`PetWindow` 应该主要负责: +- “打开微信”误判为工作区打开文件。 +- “搜索 Qt 最新版本”误判为工作区搜索。 +- “把天气截图保存到桌面”误判为天气查询。 -```text -窗口显示 -拖动 -菜单 -子组件组织 -基础 UI 信号转发 -``` +## 当前延期项 -不建议继续负责: +以下能力继续延期,不进入本地工作区 Agent 第一阶段: -```text -提醒解析 -天气查询 -联网模式 -本地文件读写 -AI 工具调度 -复杂业务状态管理 -``` +- 语音输入和语音朗读。 +- Live2D、GIF、视频形象。 +- 插件系统。 +- 云同步和自动更新。 +- 长期记忆系统。 +- 角色市场或角色包分享平台。 +- Shell 命令执行、脚本执行、自动安装依赖。 +- 自动 git commit、自动推送、自动联网下载并执行文件。 +- 工作区外全盘搜索。 +- 复杂网页抓取和旧搜索引擎聚合。 -后续应逐步拆出: +## 验收策略 -```text -ChatController -ReminderController -WeatherController -FileOperationController -ToolCommandDispatcher -``` - -即使暂时不大规模重构,也至少要为后续功能增加统一调度层,不要直接把新逻辑写进 `PetWindow::submitChatMessage()`。 - ---- - -### 2.3 新增 IntentRouter / CommandDispatcher - -后续功能包括: - -```text -定时提醒 -天气查询 -本地文件操作 -联网模式 -本地应用启动 -普通 AI 对话 -``` - -这些都来自用户输入框。如果没有统一入口,逻辑会混乱。 - -可拆成两个模块: - -```text -src/intent/ - ├── IntentTypes.h - ├── IntentRouter.h - └── IntentRouter.cpp - -src/command/ - ├── CommandDispatcher.h - └── CommandDispatcher.cpp -``` - -当前阶段已选择合并为一个轻量模块: - -```text -src/assistant/ - ├── UserIntent.h - ├── IntentRouter.h - ├── IntentRouter.cpp - ├── CommandDispatcher.h - └── CommandDispatcher.cpp -``` - -当前意图枚举: - -```cpp -enum class UserIntentType -{ - Chat, - Reminder, - Weather, - FileOperation, - LaunchApp -}; -``` - -建议入口流程: - -```text -用户输入 -↓ -IntentRouter 判断意图 -↓ -CommandDispatcher 分发 -↓ -ReminderManager / WeatherManager / FileOperationManager / WebChatManager / AppLaunchManager / ConversationManager -``` - -第一版意图识别不需要复杂,规则优先即可。 - ---- - -### 2.4 意图优先级建议 - -多个意图可能同时出现,需要固定优先级。 - -推荐: - -```text -Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat -``` - -原因: - -```text -“明天早上提醒我看天气” -虽然包含“天气”,但本质是提醒。 - -“把天气截图保存到桌面” -可能是文件操作,而不是单纯天气查询。 - -“搜索一下明天天气” -新版不再作为独立搜索工具处理;如果已有 WeatherTool,应优先走天气工具,否则按普通聊天或输入框联网开关进入 Chat/WebChat。 -``` - ---- - -### 2.5 检查 CMAKE_AUTOMOC 设置 - -如果后续新增的 Manager 使用 `QObject`、`signals`、`slots`,建议开启: - -```cmake -set(CMAKE_AUTOMOC ON) -``` - -如果继续保持 `CMAKE_AUTOMOC OFF`,则后续模块应避免 `Q_OBJECT`,全部使用普通 C++ 回调或现有信号体系。 - -建议明确选择一种: - -```text -方案 A:开启 AUTOMOC,后续工具模块正常使用 QObject 信号槽 -方案 B:保持 OFF,后续模块不得引入 Q_OBJECT -``` - -当前阶段选择方案 B:继续保持 `CMAKE_AUTOMOC OFF`,新增意图分发模块使用普通 C++ 类和同步返回值,不引入 `Q_OBJECT`。 - -后续如果 Reminder / Weather / WebChat 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。 - ---- - -### 2.6 README 与实际功能保持同步 - -当前 README 已经比较完整,但后续每实现一个功能,应同步更新: - -```text -当前状态 -配置说明 -隐私说明 -日志说明 -用户数据目录 -发布包包含内容 -``` - -尤其是以下功能需要额外说明: - -```text -提醒数据保存位置 -天气 API 请求说明 -IP 定位隐私说明 -本地文件操作权限说明 -联网模式来源和隐私说明 -``` - ---- - -### 2.7 继续保持角色导入安全策略 - -当前角色导入已经有较多安全边界,比如: - -```text -只导入本地文件夹 -验证失败不复制 -角色 id 安全字符限制 -内置角色不能被覆盖 -只允许删除用户导入角色 -状态路径安全检查 -``` - -后续本地文件操作模块也应该沿用这种风格: - -```text -路径必须安全 -不允许任意系统目录 -危险操作必须确认 -修改前备份 -删除必须二次确认 -``` - ---- - -## 3. 后续功能总路线 - -建议不要一次做完所有功能,按以下顺序推进: - -```text -阶段 0:结构收口 -阶段 1:定时提醒 -阶段 2:天气查询 -阶段 3:本地文件操作 -阶段 4:联网模式 -阶段 5:语音对话 / 更复杂 Agent 能力 -``` - -当前结构收口和定时提醒已经进入实现阶段。下一步最推荐继续做: - -```text -1. 天气查询 -2. 本地文件操作安全边界 -3. 联网模式 -``` - ---- - -# 4. 阶段 0:结构收口任务 - -## 4.1 目标 - -在正式加新功能之前,先让用户输入有统一分发入口,避免所有功能混进聊天逻辑。 - -## 4.2 当前新增文件 - -```text -src/assistant/UserIntent.h -src/assistant/IntentRouter.h -src/assistant/IntentRouter.cpp -src/assistant/CommandDispatcher.h -src/assistant/CommandDispatcher.cpp -``` - -## 4.3 IntentRouter 职责 - -```text -判断用户输入属于什么类型: -- 普通聊天 -- 定时提醒 -- 天气查询 -- 本地文件操作 -- 联网模式 -``` - -第一版可以使用规则判断,不依赖 AI。 - -## 4.4 CommandDispatcher 职责 - -```text -接收用户输入 -调用 IntentRouter -根据意图分发到不同 Manager -普通聊天才交给现有 ConversationManager / LLMProvider -``` - -## 4.5 PetWindow 改造要求 - -`PetWindow` 只负责把用户输入交给 `CommandDispatcher`,不直接判断业务逻辑。 - -示例流程: - -```text -PetWindow::submitChatMessage() -↓ -CommandDispatcher::dispatch(userText) -↓ -根据返回信号更新气泡、状态机、历史 -``` - -不要继续在 `PetWindow` 中直接写提醒、天气、搜索或文件逻辑。 - ---- - -# 5. 阶段 1:定时提醒功能 - -## 5.1 功能定位 - -实现本地一次性和基础重复提醒功能。 - -用户可以输入: - -```text -晚上8点提醒我提交作业 -明天9点提醒我开会 -10分钟后提醒我喝水 -半小时后提醒我休息 -2小时后提醒我看消息 -``` - -桌宠创建本地提醒,到点后气泡提示或托盘提示。 - -当前实现状态: - -```text -已新增 src/reminder/ 模块 -已支持一次性提醒解析、JSON 持久化、启动后加载、到点触发和状态标记 -已支持聊天创建 / 查询 / 取消提醒 -已支持设置页按状态查看提醒、取消 pending 提醒、编辑 pending 提醒、清理 20 天前已触发/已取消历史 -已支持 reminder_default / reminder_soft 内置音效 -已支持用户 wav 音效导入、删除、切换和试听 -已限制用户音效删除路径,只允许删除用户音效目录内的安全 sound id -提醒触发时使用当前设置页选择的全局音效,ReminderItem.soundId 仅保留为历史兼容字段 -已接入 Qt Multimedia / QSoundEffect 播放提醒音效 -已预留 NotificationDispatcher,当前 Windows 仍由托盘通知承接 -已支持每天 / 每周 / 每月重复提醒 -已支持提醒触发后的“知道了”和“5分钟后再提醒” -已支持提醒数据原子保存 -已支持多提醒可见队列,避免同时触发时互相覆盖 -已支持 60 秒兜底扫描,覆盖睡眠唤醒、系统时间变化和长间隔 timer 延迟场景 -通知后端不可用时会记录日志,不补气泡 -``` - -## 5.2 第一版范围 - -要做: - -```text -一次性提醒 -每天 / 每周 / 每月重复提醒 -本地保存 -程序重启后提醒不丢 -到点后触发气泡和托盘通知 -提醒触发后标记 triggered -支持查询当前提醒列表 -支持取消提醒 -``` - -暂不做: - -```text -工作日提醒 -自定义间隔重复提醒 -农历提醒 -复杂日历 -跨设备同步 -联网日程 -语音提醒 -``` - -## 5.3 建议目录 - -```text -src/reminder/ - ├── ReminderTypes.h - ├── ReminderCommandHandler.h - ├── ReminderCommandHandler.cpp - ├── ReminderParser.h - ├── ReminderParser.cpp - ├── ReminderManager.h - ├── ReminderManager.cpp - ├── ReminderStore.h - ├── ReminderStore.cpp - ├── ReminderSoundRepository.h - ├── ReminderSoundRepository.cpp - ├── ReminderSoundPlayer.h - └── ReminderSoundPlayer.cpp -``` - -## 5.4 数据结构建议 - -```cpp -struct ReminderItem -{ - QString id; - QString title; - QString originalText; - QDateTime remindAt; - ReminderStatus status = ReminderStatus::Pending; - QDateTime createdAt; - QDateTime finishedAt; // triggered/canceled 记录的完成时间;旧数据缺失时按 remindAt 兼容 - QString soundId; // 历史兼容字段,触发时不再读取 - ReminderRecurrence recurrence; -}; - -struct ReminderRecurrence -{ - ReminderRecurrenceType type = ReminderRecurrenceType::None; // None / Daily / Weekly / Monthly - int interval = 1; - int weekday = 0; // 1-7,仅 weekly 使用 - int monthDay = 0; // 1-31,仅 monthly 使用 - int hour = -1; - int minute = -1; - QDateTime lastTriggeredAt; -}; -``` - -## 5.5 存储文件 - -保存到: - -```text -QStandardPaths::AppConfigLocation/reminders.json -``` - -示例: - -```json -{ - "reminders": [ - { - "id": "reminder_001", - "title": "提交作业", - "originalText": "晚上8点提醒我提交作业", - "remindAt": "2026-06-01T20:00:00", - "status": "pending", - "createdAt": "2026-06-01T15:20:00", - "soundId": "", - "recurrence": { - "type": "none", - "interval": 1, - "weekday": 0, - "monthDay": 0, - "hour": -1, - "minute": -1 - } - } - ] -} -``` - -旧版 `reminders.json` 没有 `recurrence` 字段时按一次性提醒读取,继续兼容;没有 `finishedAt` 字段时,已触发/已取消历史按 `remindAt` 作为保留时间兜底。 - -提醒数据保存使用原子写入。写入失败时创建 / 取消会回滚内存变更,到点触发会保留 pending 并等待下次重试,不播放音效、不通知、不气泡。 - -历史记录默认只保留最近 20 天。自动清理和设置页“清理20天前历史”只删除超过 20 天的 triggered/canceled 记录,不删除 pending。 - -配置损坏时备份: - -```text -reminders.broken.yyyyMMdd-HHmmss.json -``` - -## 5.6 时间解析策略 - -第一版规则解析优先,不依赖 AI。 - -支持: - -```text -8点 -8点30 -20:30 -晚上8点 -下午3点 -明天9点 -明天上午10点 -后天9点 -今天下午3点 -6月3日9点 -6/3 09:00 -下周一上午10点 -10分钟后 -半小时后 -一个半小时后 -一小时后 -1小时后 -两小时后 -2小时后 -每天9点 -每天提醒我9点打卡 -每日晚上8点 -每周一上午10点 -每周一提醒我上午10点周会 -每星期五下午3点 -每月3号9点 -每月3号提醒我9点交报告 -``` - -如果规则解析失败,后续可以再接 AI 解析。当前重复提醒只支持每天 / 每周 / 每月。包含“工作日 / 每两天 / 每月最后一天 / 每季度 / 农历 / 自定义复杂规则”等语义时,返回明确暂不支持提示,不创建一次性提醒。 - -## 5.7 AI 辅助解析的设计边界 - -可以后续做 AI 辅助解析,但 AI 不允许直接写文件。 - -正确流程: - -```text -AI 解析用户意图 -↓ -返回结构化 JSON -↓ -程序校验 -↓ -ReminderManager 创建提醒 -↓ -ReminderStore 保存 -``` - -禁止: - -```text -AI 直接读写 reminders.json -AI 直接修改本地文件 -``` - -AI 解析时必须由程序提供当前本地时间: - -```text -currentLocalTime -timeZone -weekday -userText -``` - -因为 API 模型无法可靠知道用户本机时间。 - -## 5.8 到点触发 - -建议行为: - -```text -桌宠可见:播放当前全局音效,显示 ChatBubble + 切 happy,无 happy 时回退 talk,不发 Windows 通知 -桌宠隐藏:播放当前全局音效,触发 Windows 托盘通知,不在下次显示时补气泡 -AI 正在请求或流式回复:按隐藏场景处理,播放当前全局音效并发 Windows 通知,不显示气泡,不进入补气泡队列 -Windows 托盘通知后端不可用:记录日志,不补气泡,不进入可见队列 -用户拖动中:播放当前全局音效,不打断 drag,拖动结束后显示气泡,不发 Windows 通知 -多条提醒同时触发:可见状态下进入队列逐条展示,避免后一条覆盖前一条 -桌宠可见触发时显示轻量操作区:“知道了”关闭当前提示,“5分钟后再提醒”固定创建新的 5 分钟一次性提醒 -重复提醒触发后先推进下一次 remindAt 并保存;稍后提醒不会影响原重复规则 -``` - -提醒文案: - -```text -到时间啦:提交作业 -``` - -## 5.9 给 Codex 的阶段任务 - -```text -1. 新增 reminder 模块 -2. 实现一次性 ReminderItem -3. 实现 ReminderStore JSON 读写 -4. 实现 ReminderParser 规则解析 -5. 实现 ReminderManager 定时检查 -6. 到点后发信号给 UI 层 -7. UI 层显示气泡和托盘通知 -8. 不要把提醒逻辑写进 PetWindow -9. 不要让 AI 直接写本地文件 -``` - ---- - -# 6. 阶段 2:天气查询功能 - -当前天气查询 v1 已进入实现阶段: - -- 已新增独立 `src/weather/` 模块 -- 已支持 Open-Meteo 市级城市优先的基础地理编码和基础天气查询 -- 已支持设置页默认城市 -- 已支持默认城市为空时通过公网 IP 定位兜底 -- 已支持当前/今天、明天、后天和未来 1-3 天模板回复 -- 已支持读取前 5 个地理编码候选;多候选时仍查首项,并在回复中提示同名城市风险和其他候选 -- 已支持设置页测试默认城市;测试只展示匹配结果,不自动保存配置 -- 已通过 `CommandDispatcher` 将 Weather 意图接入聊天入口 -- 已保持 `PetWindow` 只负责 UI 展示,不承载天气解析或网络逻辑 - -v1 明确暂不支持: - -- AI 润色天气回复 -- 空气质量 -- 天气预警 -- 天气提醒联动 -- 多天气源切换 -- 小时级精细降雨判断 -- 穿衣指数 - -默认城市为空且启用公网 IP 定位时,会请求 ipapi.co 判断城市;回复必须说明“根据公网 IP 判断城市”。设置页默认城市和公网 IP 定位都属于非用户明确城市来源,回复必须说明来源。 - -当前 v1 推荐填写市级城市名;区县、乡镇、街道不保证精确识别,可能无法匹配或被匹配到上级/同名城市。同名城市当前使用天气源返回的第一个结果,并在回复或测试结果中提示其他候选。后续优先补可交互候选选择、区县级定位和国内天气源增强。 - -## 6.1 功能定位 - -天气是独立工具能力,不放进联网模式。 - -正确流程: - -```text -用户问天气 -↓ -程序识别 Weather 意图 -↓ -WeatherManager 调用天气 API -↓ -拿到结构化天气数据 -↓ -AI 根据结构化数据自然语言回复 -``` - -不要让 AI 自己搜索天气,也不要让 AI 自己拼 URL。 - -## 6.2 为什么不用联网模式做天气 - -用联网模式查询天气有几个问题: - -```text -结果格式不稳定 -摘要可能过期 -地区解析不稳定 -无法稳定提取温度、降雨、风力、湿度等字段 -AI 总结容易出错 -``` - -天气应该走天气 API。 - -## 6.3 第一版范围 - -要做: - -```text -识别天气请求 -支持默认城市 -支持用户指定城市 -支持当前天气 -支持简单今天/明天查询 -AI 不可用时模板兜底回复 -查询中切 think -回复时切 talk -失败时切 error -``` - -暂不做: - -```text -天气预警 -空气质量详情 -分钟级降水 -生活指数 -地图定位 -GPS 定位 -自动后台推送 -天气提醒 -多天气源自动切换 -``` - -## 6.4 建议目录 - -```text -src/weather/ - ├── WeatherTypes.h - ├── WeatherConfig.h - ├── WeatherStore.h - ├── WeatherStore.cpp - ├── WeatherParser.h - ├── WeatherParser.cpp - ├── WeatherSummaryFormatter.h - ├── WeatherSummaryFormatter.cpp - ├── WeatherManager.h - └── WeatherManager.cpp -``` - -## 6.5 天气源建议 - -第一版推荐: - -```text -Open-MeteoProvider -``` - -优点: - -```text -不需要 API Key -全球可用 -适合开源项目默认方案 -``` - -后续再加: - -```text -AmapWeatherProvider -OpenWeatherProvider -``` - -如果主要面向国内用户,高德天气也可以作为第二个 Provider,但需要用户配置 Web 服务 Key。 - -## 6.6 WeatherConfig - -```cpp -struct WeatherConfig -{ - bool enabled = true; - - QString provider = "open-meteo"; - - QString defaultLocationName; - double defaultLatitude = 0.0; - double defaultLongitude = 0.0; - bool hasDefaultCoordinate = false; - - bool allowIpLocation = false; - bool askBeforeIpLocation = true; - - QString apiKey; - int timeoutMs = 10000; - - bool useAIResponse = true; - bool showRawDataWhenAIUnavailable = true; -}; -``` - -保存到: - -```text -QStandardPaths::AppConfigLocation/weather_config.json -``` - -## 6.7 地点策略 - -优先级: - -```text -用户明确输入城市 -↓ -用户设置的默认城市 -↓ -用户主动选择 IP 定位 -↓ -追问用户城市 -``` - -不要默认静默使用 IP 定位。 - -## 6.8 IP 定位原则 - -IP 定位可以做,但只能作为可选辅助。 - -原因: - -```text -VPN / 代理会导致定位错误 -运营商出口可能定位不准 -校园网 / 公司网可能不准 -用户可能不希望程序自动请求定位服务 -``` - -建议交互: - -```text -你还没有设置默认城市。 -可以手动输入城市,也可以使用 IP 自动定位。 -是否使用 IP 自动定位? -``` - -识别到城市后仍要确认: - -```text -我识别到你可能在西安,要设为默认城市吗? -``` - -## 6.9 天气意图关键词 - -```text -天气 -气温 -温度 -冷不冷 -热不热 -下雨 -降雨 -带伞 -刮风 -风力 -湿度 -空气质量 -雾霾 -适合出门 -穿什么 -外面怎么样 -``` - -## 6.10 AI 回复上下文 - -当前 v1 采用模板优先,不依赖 AI 润色;本节作为 v1.1 预留方向。 - -程序应把结构化天气数据交给 AI: - -```text -用户问题: -今天西安天气怎么样? - -天气数据: -城市:西安 -当前天气:多云 -温度:26℃ -体感温度:27℃ -湿度:60% -风向:东北风 -风速:3级 -降雨概率:20% -更新时间:2026-06-01 15:00 - -请根据以上数据,用简洁自然的语气回答。不要编造没有提供的数据。 -``` - -## 6.11 AI 不可用时兜底 - -如果天气 API 成功但 AI 失败,则模板回复: - -```text -西安当前天气:多云,26℃,湿度 60%,风力 3 级。更新时间:15:00。 -``` - ---- - -# 7. 阶段 3:本地文件操作功能 - -## 7.1 功能定位 - -本地文件操作是高风险功能,必须后置,不能早于提醒和天气。 - -当前本地文件操作 v1 已进入实现阶段: - -- 已新增独立 `src/fileops/` 模块 -- 已通过 `CommandDispatcher` 将 FileOperation 意图接入聊天入口 -- 已支持读取用户主动选择的常见文本文件 -- 已支持列出用户主动选择的文件夹 -- 已支持复制文件、创建备份、重命名文件 -- 写操作会展示操作计划并二次确认 -- 聊天文本不会直接变成本地路径,所有路径必须由用户通过文件选择框选择 -- 已拒绝删除、覆盖、移动、执行脚本、运行命令和系统目录访问 -- 已拒绝符号链接路径,降低路径逃逸风险 - -v1 明确暂不支持: - -- zip 打包 -- 删除文件 -- 覆盖文件 -- 移动文件 -- 执行脚本或命令 -- 修改源码 - -目标是让桌宠能辅助用户处理本地文件,例如: - -```text -帮我整理这个文件夹里的图片 -帮我把这些日志文件打包 -帮我读取这个 txt 总结一下 -帮我把这个 md 转成备份副本 -``` - -## 7.2 基本原则 - -AI 不能直接操作本地文件。 - -正确流程: - -```text -AI 理解用户意图 -↓ -生成操作计划 -↓ -程序校验路径和权限 -↓ -展示计划给用户确认 -↓ -程序执行 -↓ -执行结果反馈给 AI / 用户 -``` - -禁止: - -```text -AI 直接读写任意文件 -AI 直接删除文件 -AI 直接覆盖文件 -AI 自己决定访问系统目录 -``` - -## 7.3 安全边界 - -必须实现: - -```text -工作目录白名单 -路径标准化 -禁止符号链接逃逸 -禁止访问系统目录 -禁止访问用户隐私目录,除非用户显式选择 -修改前备份 -删除前二次确认 -批量操作前显示操作计划 -``` - -## 7.4 建议目录 - -```text -src/fileops/ - ├── FileOperationTypes.h - ├── FileOperationManager.h - ├── FileOperationManager.cpp - ├── FileSandbox.h - ├── FileSandbox.cpp - ├── FileBackupManager.h - └── FileBackupManager.cpp -``` - -## 7.5 第一版建议只做低风险操作 - -第一版可做: - -```text -读取用户主动选择的文本文件 -列出用户主动选择的文件夹 -复制文件 -创建备份 -打包 zip -重命名文件,需确认 -``` - -当前 v1 中 zip 打包延期,因为 Qt 公共 API 没有稳定内置写 zip 能力;不为此引入重依赖。后续如果要补 zip,优先选择明确许可和可维护的压缩库。 - -暂不做: - -```text -删除文件 -覆盖文件 -移动大量文件 -修改源码 -执行脚本 -运行命令 -访问系统目录 -``` - -## 7.6 修改/删除必须二次确认 - -即使用户说“删掉它”,也必须确认: - -```text -即将删除: -D:/xxx/a.txt -D:/xxx/b.txt - -此操作会移动到回收站/备份后删除。 -是否继续? -``` - -## 7.7 应用启动独立模块 - -当前已新增独立 `src/launcher/` 模块,不放进 `src/fileops/`: - -```text -src/launcher/ - ├── AppLaunchTypes.h - ├── AppLaunchStore.h - ├── AppLaunchStore.cpp - ├── AppDiscovery.h - ├── AppDiscovery.cpp - ├── AppLaunchManager.h - └── AppLaunchManager.cpp -``` - -应用启动支持: - -```text -打开 Codex -启动酷狗音乐 -帮我打开 VSCode -``` - -解析和发现顺序: - -```text -用户在设置页登记的应用别名 -↓ -Windows 开始菜单快捷方式 -↓ -Windows App Paths 注册表 -↓ -未知应用时由用户手动选择 .exe -``` - -启动安全边界: - -```text -启动前始终二次确认 -只允许 .exe 或开始菜单 .lnk -不执行 .bat / .cmd / .ps1 / .vbs / .js / .msi -不执行聊天文本里的命令 -不拼接聊天文本参数 -不以管理员权限启动 -``` - -配置保存到: - -```text -QStandardPaths::AppConfigLocation/launcher_config.json -``` - -损坏时备份为: - -```text -launcher_config.broken.yyyyMMdd-HHmmss.json -``` - ---- - -# 8. 阶段 4:联网模式 - -联网能力已从旧“搜索引擎聚合器”重做为 Web 端 AI 对话式联网模式。 - -当前已落地: - -- 已删除旧 `src/search/` 模块,不再维护 Google/百度/360/搜狗页面解析、SearXNG 旧配置、多源聚合和旧 `search_config.json` 兼容。 -- 已新增独立 `src/web/` 模块:`WebConfig`、`WebStore`、`WebCapabilityDetector`、`WebChatManager` 和 Web citation 类型。 -- 输入框新增“联网”开关;开关开启后,普通聊天进入 WebChat 流程。 -- 支持 OpenAI 官方 Provider 的 Responses API Web Search。 -- 支持 Google Gemini Provider 的 Google Search grounding。 -- DeepSeek 官方 API 当前不提供托管联网搜索工具,显示“不支持原生联网”。 -- Custom / 第三方 OpenAI-Compatible 默认无法确认联网能力,不发送未知联网参数,不做旧搜索页抓取。 -- 设置页改为“联网模式”:显示当前能力状态、开关记忆、默认开关、Provider 模式、超时、来源展示和测试联网模式。 -- 旧 `search_config.json` 已废弃;新版使用 `web_config.json`,损坏时备份为 `web_config.broken.yyyyMMdd-HHmmss.json`。 - -边界: - -- 不再默认每次联网,模型可判断稳定常识无需联网。 -- 不做搜索结果页 HTML 解析,避免登录、帮助、反馈、验证码页面污染答案。 -- 不实现 Tavily / Brave / Bing Search API、自建搜索后端、网页全文抓取或长期缓存。 -- 天气仍是独立工具能力,不放进联网模式。 - -后续可选: - -- 更多 AI Provider 原生联网适配。 -- 结构化搜索 API 或自建联网后端,用于不支持原生联网的模型。 -- 更强的引用 UI、证据片段摘录和来源可点击展示。 - -# 9. 建议最终开发顺序 - -## 9.1 第一步:收口架构 - -```text -1. 角色切换保存后立即生效已确认,后续作为回归项保留 -2. 新增 IntentRouter -3. 新增 CommandDispatcher -4. 调整 PetWindow 输入流程 -5. 当前阶段保持 CMAKE_AUTOMOC OFF,新增模块不使用 Q_OBJECT -``` - -## 9.2 第二步:定时提醒 - -```text -当前已落地一次性提醒、每天/每周/每月重复提醒、提醒列表、取消提醒、编辑 pending 提醒、20 天历史保留、到点气泡/托盘通知、稍后提醒和提醒音效管理。 -后续可继续补工作日/自定义间隔、跨平台通知实现和更复杂的提醒管理能力。 -``` - -## 9.3 第三步:天气查询 - -```text -当前已落地天气 v1:WeatherTypes、WeatherConfig / WeatherStore、WeatherParser、WeatherManager、模板回复、设置页默认城市、公网 IP 定位兜底和 Weather 意图接入。 -当前已落地天气 v1.2 / v1.3:多候选城市提示和设置页默认城市测试。后续可继续补可交互候选选择、区县级定位、国内天气源增强、AI 润色、空气质量、天气预警、多天气源切换、小时级降雨判断和天气提醒联动。 -``` - -## 9.4 第四步:本地文件操作 - -```text -当前已落地 FileSandbox、FileOperationTypes、FileOperationManager、FileBackupManager 和用户确认 UI。 -v1 支持读取文本、列出文件夹、复制、备份、重命名。 -v1 不支持 zip、删除、覆盖、移动、脚本/命令执行和系统目录访问。 -``` - -## 9.5 第五步:联网模式 - -```text -当前已落地 WebConfig、WebStore、WebCapabilityDetector、WebChatManager 和输入框联网开关。新版支持 OpenAI/Gemini 原生联网;DeepSeek/Custom 明确提示不支持或无法确认;旧搜索聚合与 SearXNG 旧配置已废弃。 -``` - -## 9.6 第六步:应用启动 - -```text -当前已落地 AppLaunchStore、AppDiscovery、AppLaunchManager 和设置页应用登记。 -v1 支持已登记应用、开始菜单快捷方式、App Paths 注册表和用户确认手选 .exe。 -v1 不支持脚本、聊天参数、命令执行、管理员权限和跨平台应用发现。 -``` - ---- - -# 10. 给 Codex 的总提示 - -下面这段可以直接作为后续任务总说明: - -```text -当前不要急着直接实现提醒、天气、文件操作或联网模式。 - -请先审查当前 QtDesktopPet 项目结构,并完成以下准备工作: - -1. 角色切换在设置保存后已确认立即生效,后续只需保留回归检查。 -2. 新增 IntentRouter / CommandDispatcher,使用户输入先经过统一意图分发。 -3. 意图类型包括 Chat、Reminder、Weather、FileOperation、LaunchApp;联网模式由输入框开关决定,不再是独立 Search 意图。 -4. 意图优先级为 Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat。 -5. 普通聊天继续走现有 AI 对话流程。 -6. 新功能不得继续直接塞进 PetWindow。 -7. 后续 Reminder、Weather、FileOperation、LaunchApp、WebChat 均应作为独立模块接入。 -8. 当前阶段保持 CMAKE_AUTOMOC OFF,后续模块不要使用 Q_OBJECT;确需 Qt 信号槽时再单独评估。 -9. 保持现有隐私策略:API Key、Authorization、完整用户消息、完整错误响应不得写入日志。 -10. 保持现有文件安全策略:路径校验、危险操作确认、修改前备份。 -``` - ---- - -# 11. 当前结论 - -项目已经可以进入“工具能力扩展阶段”,但不建议直接开写业务功能。 - -建议先做: - -```text -结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网模式 -``` - -其中: - -```text -定时提醒:已作为第一个工具能力落地 -天气:建议第二个落地 -本地文件操作:风险较高,第三个落地 -联网模式:通用 AI 对话增强,最后落地 -``` +- Codex 不调用 CMake、`cmake --build`、Qt Creator 构建或等价构建命令。 +- 每完成一个模块,先做静态检查和逻辑推演。 +- 编译和运行验证由用户手动执行。 +- 功能验收按 `docs/QtDesktopPet_测试清单与验收标准.md`。 +- 性能和发布验收按 `docs/performance_stability_check.md`。 diff --git a/docs/QtDesktopPet_测试清单与验收标准.md b/docs/QtDesktopPet_测试清单与验收标准.md index 6948701..2dc6c4c 100644 --- a/docs/QtDesktopPet_测试清单与验收标准.md +++ b/docs/QtDesktopPet_测试清单与验收标准.md @@ -109,6 +109,8 @@ Qt 版本: ## 本地文件操作 +当前源码仍保留安全受限的本地文件操作 v1。后续将由本地工作区 Agent 替代;替代完成前,以下用例仍作为回归项保留。 + | 用例 | 步骤 | 预期结果 | 结论 | | --- | --- | --- | --- | | 读取文本 | 输入读取文本文件请求并选择 txt/md/log | 显示内容预览,最多读取前 64KB | TODO | @@ -122,6 +124,56 @@ Qt 版本: | 符号链接拒绝 | 选择符号链接或链接目录内路径 | 提示不允许操作符号链接路径 | TODO | | zip 延期 | 输入打包/压缩请求 | 提示 zip 打包暂不启用 | TODO | +## 本地工作区 Agent + +v1a.1/v1b/v1b.5 已接入工作区只读分析、打开定位和独立 Agent 窗口;创建、修改、移动、删除到回收站仍属后续阶段。正式施工计划见 `docs/agent/DesktopPet_本地工作区Agent_施工计划.md`。 + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 选择工作区 | 选择普通项目目录 | 显示当前工作区并扫描索引 | TODO | +| 系统目录拒绝 | 选择 `C:/Windows` 或 `Program Files` | 拒绝作为工作区 | TODO | +| 扫描跳过目录 | 工作区含 `.git/build/node_modules/release_packages` | 扫描跳过这些目录,结果数量受限制 | TODO | +| 搜索文件名 | 输入 `搜索 README` | 返回最多 30 个相对路径结果 | TODO | +| 独立 Agent 窗口 | 输入 `搜索 README` | 独立窗口展示上一轮结果,可选中结果;左侧结果/文本命中/上下文、中间对话、右侧详情分区清晰 | TODO | +| 窗口非客户区 | 打开独立 Agent 窗口 | 没有白色系统标题栏,标题区与整体深色 UI 协调,顶部可拖动移动窗口 | TODO | +| 搜索文本 | 输入 `搜索包含 class 的文件` | 返回路径、行号和片段,跳过二进制和敏感文件 | TODO | +| 紧凑文本搜索 | 输入 `含class的文件`、`包含 class 的文件`、`找一下含 class 的文件` | 走 Workspace 文本搜索,不弹旧文本文件选择框,关键词提取为 `class` | TODO | +| 文本命中窗口 | 输入 `搜索包含 class 的文件` | 独立窗口展示文本命中和关联文件列表,选中文本命中时右侧详情同步 | TODO | +| 读取文本文件 | 输入 `读取 main.cpp` | 读取工作区内文本文件,超大文件截断 | TODO | +| 窗口读取 | 搜索后在独立窗口选中结果并点击 `读取` | 读取选中文本文件,目录项读取按钮禁用 | TODO | +| 敏感文件拒绝 | 读取 `.env`、`*.pem`、`credentials.json` | 默认拒绝或强确认,不展示敏感内容 | TODO | +| 项目结构摘要 | 输入 `分析这个项目` | 显示本地结构摘要;AI 可用且空闲时补简短分析 | TODO | +| 文件总结 | 读取文件后输入 `总结这个文件` | 显示本地内容,AI 可用且空闲时补简短总结 | TODO | +| 文件分析同义词 | 读取 `main.cpp` 后输入 `分析这个文件`、`解释这个文件`、`看看这个文件` | 走 Workspace 最近读取文件分析,不弹旧文本文件选择框 | TODO | +| 文件名分析 | 输入 `分析 main.cpp`、`解释 README`、`main.cpp 是干嘛的` | 走 Workspace 文件读取和 AI 分析流程,不落入普通 AI 对话 | TODO | +| 总结无上下文 | 未读取工作区文件时输入 `总结这个文件` | 提示先读取一个工作区文件或输入具体文件名,不弹旧文件选择框 | TODO | +| 读取文件泛请求 | 输入 `读取文件` | 仍走旧 fileops 文本文件选择流程 | TODO | +| 上下文引用 | 先搜索 README,再输入 `读取第一个` | 正确引用上一轮结果;上下文过期则要求重新选择 | TODO | +| 打开文件 | 先搜索 README,再输入 `打开第一个` | 弹出确认框,确认后用系统默认程序打开;取消不打开 | TODO | +| 窗口打开 | 搜索后在独立窗口选中结果并点击 `打开` | 文件弹确认后打开;目录直接打开 | TODO | +| 打开目录 | 输入 `打开 docs` 或 `打开这个项目里的 docs` | 打开工作区内目录 | TODO | +| 显示位置 | 输入 `显示 main.cpp 所在位置` 或 `打开所在文件夹` | 打开工作区内文件所在目录 | TODO | +| 窗口显示位置 | 搜索后在独立窗口选中结果并点击 `位置` | 打开选中项所在目录 | TODO | +| 右键入口 | 右键桌宠选择 `Agent 工作区` | 独立 Agent 窗口打开;未选择工作区时显示空状态,不强制选择目录 | TODO | +| 托盘入口 | 托盘菜单选择 `Agent 工作区` | 独立 Agent 窗口打开;桌宠隐藏时也可直接打开窗口 | TODO | +| 默认快捷键 | 按 `Alt+A` | 呼出独立 Agent 窗口 | TODO | +| Agent 设置 | 设置页 `Agent` 页关闭快捷键或修改快捷键后保存 | 快捷键立即按新配置生效,非法/冲突快捷键有明确提示 | TODO | +| 自动显示设置 | 关闭 `工作区命令完成后自动显示 Agent 窗口` 后输入 `搜索 README` | 搜索正常完成,但独立窗口不自动弹出;手动入口仍可打开 | TODO | +| 打开敏感/危险文件拒绝 | 尝试打开 `.exe/.ps1/.lnk/.env/*.pem/credentials.json` | 明确拒绝,不调用系统打开 | TODO | +| 打开路径越界拒绝 | 尝试打开工作区外、系统目录、符号链接路径 | 明确拒绝 | TODO | +| 创建误分发修复 | 输入 `创建 test.txt` | 提示当前不支持创建/写入,不弹读取文本文件选择框 | TODO | +| 写入误分发修复 | 输入 `新建文件`、`写入 README`、`修改 main.cpp`、`修复 main.cpp`、`优化 main.cpp` | 明确拒绝当前阶段写操作,不弹读取文本文件选择框 | TODO | +| 危险能力拒绝 | 请求运行脚本、执行命令、自动安装依赖、自动 git commit | 明确拒绝,不调用系统命令 | TODO | + +后续阶段验收项: + +| 用例 | 步骤 | 预期结果 | 结论 | +| --- | --- | --- | --- | +| 创建文件 | 创建 `test.txt` | 展示计划,确认后创建;目标存在拒绝 | TODO | +| 修改文件 | 修改普通文本文件 | 展示计划,确认后写入;写入前生成备份 | TODO | +| 移动/重命名 | 移动或重命名单文件 | 展示计划,确认后执行;工作区外和覆盖目标拒绝 | TODO | +| 删除到回收站 | 删除普通文件 | 强确认后移动到回收站;永久删除和批量删除拒绝 | TODO | + ## 联网模式 | 用例 | 步骤 | 预期结果 | 结论 | @@ -193,6 +245,7 @@ UI 状态: | 角色 | 设置页角色页和 `CharacterPackageRepository` | 导入先验证再复制;切换保存后立即生效;用户角色可删除;任意角色可导出;可打开用户角色目录 | 内置角色不可删除/覆盖;导出目标存在需二次确认;损坏角色包不复制 | 目标目录权限失败、用户取消、角色包资源缺失 | 当前角色管理补全完成,角色市场/分享延期 | | 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 | | 文件操作 | `CommandDispatcher -> PetWindow::handleFileOperationChatMessage() -> FileOperationManager` | 用户通过系统对话框选择路径;读取文本、列目录、复制、备份、重命名;写操作先展示计划并二次确认 | 删除/覆盖/移动/脚本/命令/截图保存/zip 直接拒绝;系统目录、符号链接、目标冲突、非法文件名均拒绝 | 文件选择对话框是安全边界;系统目录根据常见路径和环境变量识别 | v1 安全能力完成,zip 打包延期,不引入重依赖 | -| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;不支持 DeepSeek/Custom 伪联网 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 | +| 本地工作区 Agent | `CommandDispatcher -> PetWindow::handleWorkspaceChatMessage() -> WorkspaceController -> WorkspaceAgent/WorkspaceTool -> WorkspaceAgentWindow` | v1b.5 用户选择工作区后,扫描索引、搜索文件/文本、读取文本文件、生成本地摘要,AI 可用时补简短分析,并支持打开普通文件、打开文件夹、显示所在位置、独立 Agent 窗口、菜单入口和全局快捷键 | 工作区外路径、系统目录、符号链接、危险后缀、敏感文件、命令执行均拒绝;打开文件前确认;AI 忙时不阻塞本地结果 | 路径归一化、符号链接逃逸、敏感文件判断、意图误判、全局快捷键冲突是核心风险;写操作仍未进入当前阶段 | v1a.1/v1b/v1b.5 独立窗口化已落地;低风险写操作计划留到后续 | +| 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 | | 应用启动 | `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 或构建命令 | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8eab9fd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# QtDesktopPet 文档索引 + +本文档用于区分当前有效文档和历史归档文档,避免后续开发继续引用过时路线。 + +## 当前有效文档 + +| 文档 | 用途 | +| --- | --- | +| `QtDesktopPet_后续功能规划与结构审查.md` | 当前功能状态、下一阶段路线和结构边界 | +| `QtDesktopPet_测试清单与验收标准.md` | 功能测试清单、回归验收标准和静态推演记录 | +| `performance_stability_check.md` | 性能采样、稳定性检查和发布验证入口 | +| `agent/DesktopPet_本地工作区Agent_施工计划.md` | 本地工作区 Agent 的正式施工计划 | + +## 归档文档 + +历史文档统一放在 `docs/archive/`。这些文档保留为开发上下文,不再作为当前施工依据。 + +| 文档 | 归档原因 | +| --- | --- | +| `archive/implementation_plan.md` | 早期分阶段实施计划,当前大部分阶段已完成或被后续模块替代 | +| `archive/Qt_DesktopPet_开发文档.md` | 初版开发总纲,当前产品能力已超出第一版范围 | +| `archive/QtDesktopPet_后续功能规划与结构审查_旧版.md` | 旧后续规划过长,包含大量已完成阶段和旧文件操作路线 | +| `archive/DesktopPet 本地工作区 Agent 开发文档_草案.md` | 本地工作区 Agent 初稿,已整理为正式施工计划 | + +## 维护规则 + +- 新功能进入开发前,先更新当前路线文档和对应测试清单。 +- 功能完成后,同步更新 README、测试清单和性能检查项。 +- 历史文档不再直接修改,除非补充归档说明。 +- GitHub 公开导出不包含 `docs/`;开发仓库保留完整文档。 +- 在构建卡住问题解决前,文档中的验证步骤仍以用户手动构建为准,不由 Codex 调用 CMake 或构建命令。 diff --git a/docs/agent/DesktopPet_本地工作区Agent_施工计划.md b/docs/agent/DesktopPet_本地工作区Agent_施工计划.md new file mode 100644 index 0000000..a4e5e11 --- /dev/null +++ b/docs/agent/DesktopPet_本地工作区Agent_施工计划.md @@ -0,0 +1,869 @@ +# DesktopPet 本地工作区 Agent 施工计划 + +## 1. 定位 + +本地工作区 Agent 是下一阶段核心功能。它不是继续维护旧版“文件操作 v1”,而是新增一个独立的工作区系统: + +> 用户选择一个本地工作区目录后,桌宠可以读取、搜索、分析该目录内的文件,并在用户确认后创建、修改、复制、移动、重命名或删除到回收站。 + +第一阶段优先实现文件级工具,不做 Shell 命令执行,不做脚本执行,不做自动安装依赖。 + +## 2. 总原则 + +- 不允许 AI 直接获得全盘文件权限。 +- 不允许 AI 自由执行系统命令。 +- 不允许永久删除文件。 +- 不允许绕过用户确认修改本地文件。 +- 不允许默认读取敏感文件。 +- 所有写操作必须先生成计划,展示路径、动作和风险,用户确认后才能执行。 +- 所有文件路径必须限制在用户选择的工作区内。 +- `PetWindow` 只负责 UI 入口、展示和确认,不承载复杂业务逻辑。 +- 新模块先新增、后替换旧 `src/fileops/`。 + +## 3. 分期目标 + +当前执行状态: + +- v1a.1/v1b/v1b.5 已落地到 `src/workspace/` 和 `src/ui/WorkspaceAgentWindow.*`。 +- 当前不持久化工作区路径,不执行写操作,不替换旧 `src/fileops/`。 +- v1b 已支持打开工作区内普通文件、打开文件夹、显示文件所在位置。 +- v1b.5 已提供独立 Agent 工作区窗口、右键/托盘入口和默认 `Alt+A` 呼出快捷键;窗口包含左侧结果导航、中间任务对话、右侧详情/上下文和 v1c 预留变更区。 + +### v1a:只读工作区 + +目标: + +- 选择工作区。 +- 显示当前工作区。 +- 扫描工作区文件树。 +- 搜索文件名。 +- 搜索文本内容。 +- 读取文本文件。 +- 分析项目结构。 +- 总结文件内容。 + +验收: + +- 工作区外路径被拒绝。 +- 系统目录被拒绝。 +- `.git/build/node_modules/dist/release_packages` 等目录被跳过。 +- 敏感文件默认拒绝读取。 +- 二进制文件拒绝读取。 + +### v1b:打开和定位 + +状态:已落地。 + +目标: + +- 打开工作区内普通文件。 +- 打开工作区内文件夹。 +- 显示文件所在位置。 + +验收: + +- 危险后缀拒绝打开。 +- `.lnk` 不直接打开。 +- 工作区外路径拒绝。 +- 打开文件必须二次确认,打开文件夹作为低风险操作直接执行。 + +### v1c:低风险写入 + +进入 v1c 前已完成: + +- `创建 test.txt / 新建文件 / 写入 README / 修改 main.cpp` 明确拒绝,不再误进入旧 fileops 读取文件流程。 +- 独立 Agent 窗口可以分区展示上一轮结果、文本命中、上下文、选中项详情和最近读取文件。 +- 窗口支持对选中结果执行读取、打开和显示位置。 +- 窗口可通过桌宠右键菜单、托盘菜单和可配置全局快捷键呼出。 + +目标: + +- 创建文本文件。 +- 创建文件夹。 +- 复制单个文件。 +- 同目录重命名文件。 + +验收: + +- 所有写操作必须确认。 +- 目标文件或目录已存在时拒绝覆盖。 +- 文件名非法时拒绝。 +- 危险后缀拒绝创建或写入。 + +### v1d:高风险写入 + +目标: + +- 修改已有文本文件。 +- 移动单个文件。 +- 删除文件到回收站。 +- 删除文件夹到回收站。 + +验收: + +- 修改前必须备份。 +- 移动和删除必须强确认。 +- 删除只进入回收站。 +- 批量删除第一阶段拒绝。 +- 失败时不能破坏原文件。 + +### v1e:替换旧文件操作 + +目标: + +- `FileOperation` 意图迁移为 `Workspace`。 +- `PetWindow::handleFileOperationChatMessage()` 过渡为调用 `WorkspaceController`。 +- 新模块覆盖旧能力后删除旧 `src/fileops/`。 + +验收: + +- 无 `FileOperationManager` 有效引用。 +- CMake 不再登记旧 fileops 文件。 +- README 和测试文档改为“本地工作区 Agent”。 + +## 4. 新目录结构 + +建议新增: + +```text +src/workspace/ +├── WorkspaceTypes.h +├── WorkspaceSession.h +├── WorkspaceSession.cpp +├── WorkspaceIndex.h +├── WorkspaceIndex.cpp +├── WorkspaceScanner.h +├── WorkspaceScanner.cpp +├── WorkspaceAccessPolicy.h +├── WorkspaceAccessPolicy.cpp +├── WorkspaceTool.h +├── WorkspaceToolRegistry.h +├── WorkspaceToolRegistry.cpp +├── WorkspaceAgent.h +├── WorkspaceAgent.cpp +├── WorkspaceController.h +├── WorkspaceController.cpp +├── WorkspaceHistory.h +├── WorkspaceHistory.cpp +└── tools/ + ├── ListFilesTool.h + ├── ListFilesTool.cpp + ├── ReadFileTool.h + ├── ReadFileTool.cpp + ├── SearchFilesTool.h + ├── SearchFilesTool.cpp + ├── SearchTextTool.h + ├── SearchTextTool.cpp + ├── OpenFileTool.h + ├── OpenFileTool.cpp + ├── CreateFileTool.h + ├── CreateFileTool.cpp + ├── CreateDirectoryTool.h + ├── CreateDirectoryTool.cpp + ├── WriteFileTool.h + ├── WriteFileTool.cpp + ├── CopyFileTool.h + ├── CopyFileTool.cpp + ├── MoveFileTool.h + ├── MoveFileTool.cpp + ├── RenameFileTool.h + ├── RenameFileTool.cpp + ├── DeleteToTrashTool.h + └── DeleteToTrashTool.cpp +``` + +第一阶段继续保持 `CMAKE_AUTOMOC OFF`,新增业务类不使用 `Q_OBJECT`。 + +## 5. 核心类型 + +```cpp +enum class WorkspacePermissionMode +{ + ReadOnly, + ConfirmBeforeWrite, + Advanced +}; +``` + +第一阶段默认 `ConfirmBeforeWrite`,但所有写操作仍必须确认。`Advanced` 只预留,不启用自动写入。 + +```cpp +enum class WorkspaceToolRisk +{ + ReadOnly, + Low, + Medium, + High, + Dangerous +}; +``` + +风险定义: + +- `ReadOnly`:列目录、搜索、读取普通文本文件。 +- `Low`:打开文件夹、显示文件所在位置。 +- `Medium`:打开普通文件、创建文件、创建文件夹、复制文件。 +- `High`:修改文件、移动文件、重命名文件、删除到回收站。 +- `Dangerous`:永久删除、执行命令、运行脚本、修改系统目录。 + +第一阶段遇到 `Dangerous` 直接拒绝。 + +```cpp +struct WorkspaceFileInfo +{ + QString relativePath; + QString absolutePath; + QString fileName; + QString suffix; + qint64 sizeBytes = 0; + QDateTime lastModified; + bool isDirectory = false; + bool isTextFile = false; + bool isHidden = false; +}; +``` + +```cpp +enum class WorkspaceActionType +{ + Unknown, + ListFiles, + SearchFiles, + SearchText, + ReadFile, + OpenFile, + OpenDirectory, + RevealInExplorer, + CreateFile, + CreateDirectory, + WriteFile, + CopyFile, + MoveFile, + RenameFile, + DeleteToTrash +}; +``` + +```cpp +struct WorkspaceActionPlan +{ + QString id; + WorkspaceActionType type = WorkspaceActionType::Unknown; + WorkspaceToolRisk risk = WorkspaceToolRisk::ReadOnly; + QString title; + QString description; + QString sourcePath; + QString targetPath; + QString relativeSourcePath; + QString relativeTargetPath; + QString content; + QString searchKeyword; + QStringList warnings; + bool requiresConfirmation = false; + bool allowOverwrite = false; + bool confirmed = false; +}; +``` + +```cpp +struct WorkspaceToolResult +{ + bool success = false; + QString message; + QString errorMessage; + QString outputText; + QString targetPath; + QVector files; +}; +``` + +## 6. 安全策略 + +`WorkspaceAccessPolicy` 是最重要的模块,所有工具必须经过它校验。 + +职责: + +- 路径标准化。 +- 判断路径是否在工作区内。 +- 阻止 `../` 跳出工作区。 +- 阻止符号链接跳出工作区。 +- 阻止系统目录。 +- 阻止危险文件类型。 +- 阻止敏感文件默认读取。 +- 限制写入行为。 +- 限制删除行为。 + +建议接口: + +```cpp +class WorkspaceAccessPolicy +{ +public: + explicit WorkspaceAccessPolicy(const QString &workspaceRoot); + + QString workspaceRoot() const; + QString normalizePath(const QString &path) const; + QString toAbsolutePath(const QString &relativeOrAbsolutePath) const; + QString toRelativePath(const QString &absolutePath) const; + + bool isInsideWorkspace(const QString &path, QString *errorMessage = nullptr) const; + bool hasSymlinkSegment(const QString &path, QString *errorMessage = nullptr) const; + bool isSystemPath(const QString &path) const; + bool isDangerousSuffix(const QString &path) const; + bool isSensitiveFile(const QString &path) const; + bool isSupportedTextFile(const QString &path) const; + + bool canReadFile(const QString &path, QString *errorMessage = nullptr) const; + bool canReadDirectory(const QString &path, QString *errorMessage = nullptr) const; + bool canCreateFile(const QString &path, QString *errorMessage = nullptr) const; + bool canCreateDirectory(const QString &path, QString *errorMessage = nullptr) const; + bool canWriteFile(const QString &path, QString *errorMessage = nullptr) const; + bool canMoveFile(const QString &sourcePath, const QString &targetPath, QString *errorMessage = nullptr) const; + bool canDeleteToTrash(const QString &path, QString *errorMessage = nullptr) const; +}; +``` + +禁止危险后缀: + +```text +.exe .bat .cmd .ps1 .vbs .msi .scr .reg .com .pif .lnk +``` + +敏感文件默认拒绝: + +```text +.env +*.pem +*.key +id_rsa +id_ed25519 +*.pfx +*.p12 +credentials.json +secrets.json +token.json +``` + +文件名包含以下关键词也视为敏感: + +```text +secret +token +password +credential +private +apikey +api_key +``` + +## 7. 扫描和索引 + +`WorkspaceScanner` 第一版使用 `QDir` + 手动目录栈扫描工作区,后续如需取消、进度回调或更细粒度过滤,再考虑替换为 `QDirIterator` 或异步扫描。 + +默认限制: + +- 最大递归深度:8。 +- 最大文件数量:5000。 +- 跳过隐藏目录。 +- 跳过常见产物目录。 + +跳过目录: + +```text +.git +.vs +.idea +build +cmake-build-debug +cmake-build-release +node_modules +dist +release +release_packages +logs +``` + +`WorkspaceIndex` 第一版只做内存索引: + +```cpp +class WorkspaceIndex +{ +public: + void clear(); + void setFiles(const QVector &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; +}; +``` + +## 8. 工具清单 + +### 8.1 只读工具 + +`ListFilesTool` + +- 列出工作区内目录。 +- 最多显示 200 项。 +- 不递归或浅递归。 + +`ReadFileTool` + +- 只读文本文件。 +- 最大读取 128KB。 +- 超出截断。 +- 敏感文件默认拒绝。 +- 不读二进制文件。 + +支持后缀: + +```text +.cpp .h .hpp .c .cc .py .js .ts .json .md .txt .ini .xml .yaml .yml .cmake .pro .css .html +``` + +`SearchFilesTool` + +- 只搜索当前工作区索引。 +- 最多返回 30 个结果。 +- 按相关性和最近修改时间排序。 + +`SearchTextTool` + +- 最多扫描 1000 个文本文件。 +- 单文件最大读取 128KB。 +- 最多返回 50 条匹配。 +- 每条显示文件路径、行号、片段。 + +### 8.2 打开工具 + +`OpenFileTool` + +- 用系统默认程序打开文件。 +- 只能打开工作区内普通文件。 +- 危险后缀拒绝。 +- 敏感文件需要确认或第一版拒绝。 + +`OpenDirectoryTool` + +- 打开工作区内目录。 +- 工作区外路径拒绝。 + +`RevealInExplorerTool` + +- 第一版可以先打开所在文件夹。 +- 如果后续使用 `explorer.exe /select`,必须固定程序路径和参数,不接受聊天文本命令。 + +### 8.3 写入工具 + +`CreateFileTool` + +- 只能在工作区内创建。 +- 目标不能已存在。 +- 文件名必须合法。 +- 危险后缀拒绝。 +- 必须确认。 + +`CreateDirectoryTool` + +- 只能在工作区内创建。 +- 目标不能已存在。 +- 必须确认。 + +`WriteFileTool` + +- 修改已有文本文件前自动备份。 +- 禁止覆盖二进制文件。 +- 禁止写敏感文件。 +- 禁止写危险后缀。 +- 必须确认。 + +备份目录: + +```text +/.desktoppet/backups/ +``` + +备份命名: + +```text +relative_path.replace("/", "__") + "." + yyyyMMdd-HHmmss + ".bak" +``` + +`CopyFileTool` + +- 只复制单文件。 +- 源和目标都必须在工作区内。 +- 不覆盖。 +- 必须确认。 + +`MoveFileTool` + +- 只移动单文件。 +- 源和目标都必须在工作区内。 +- 不覆盖。 +- 必须确认。 + +`RenameFileTool` + +- 只允许同目录重命名。 +- 文件名不能包含路径分隔符。 +- 不覆盖。 +- 必须确认。 + +`DeleteToTrashTool` + +- 只移动到系统回收站。 +- 不永久删除。 +- 单文件删除第一阶段可支持。 +- 文件夹删除需要强确认。 +- 批量删除第一阶段拒绝。 + +## 9. WorkspaceAgent + +第一版采用半自动 Agent,不做复杂工具循环。 + +推荐流程: + +```text +用户输入 + ↓ +WorkspaceController 判断工作区任务 + ↓ +程序根据关键词和索引检索相关文件 + ↓ +读取少量相关文件 + ↓ +把文件树摘要、相关文件内容、用户问题交给模型 + ↓ +模型返回分析结果或修改建议 + ↓ +涉及写操作时生成 WorkspaceActionPlan + ↓ +用户确认 + ↓ +执行工具 +``` + +工作区专用系统提示词: + +```text +你是 QtDesktopPet 的本地工作区助手。 + +你只能基于用户授权的工作区文件进行分析。 +你不能假设自己看过未读取的文件。 +你不能要求访问工作区外路径。 +你不能执行系统命令。 +你不能生成危险操作指令。 +涉及创建、修改、移动、重命名、删除文件时,必须先生成清晰的操作计划,等待用户确认。 +涉及敏感文件、密钥、凭证时,必须提醒风险。 +回答代码问题时要引用具体文件路径。 +当信息不足时,说明还需要读取哪些文件。 +``` + +后续可升级为工具循环,但第一版不做。 + +## 10. WorkspaceController + +建议接口: + +```cpp +class WorkspaceController +{ +public: + explicit WorkspaceController(); + + bool hasWorkspace() const; + QString workspaceRoot() const; + + bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr); + void closeWorkspace(); + + WorkspaceToolResult scanWorkspace(); + WorkspaceToolResult handleMessage(const QString &message); + + bool hasPendingPlan() const; + WorkspaceActionPlan pendingPlan() const; + WorkspaceToolResult confirmPendingPlan(); + void cancelPendingPlan(); + +private: + WorkspaceSession m_session; + WorkspaceAgent m_agent; + std::optional m_pendingPlan; +}; +``` + +原则: + +- `handleMessage()` 不直接执行高风险写操作。 +- 需要确认时保存到 `m_pendingPlan`。 +- UI 调用 `confirmPendingPlan()` 后才执行。 +- 所有错误通过 `WorkspaceToolResult` 返回,不在工具层弹窗。 + +## 11. UI 接入 + +聊天入口支持: + +```text +打开工作区 +选择工作区 +当前工作区 +扫描工作区 +分析这个项目 +搜索当前工作区 +读取 main.cpp +打开第一个 +``` + +如果没有选择工作区,提示用户选择目录。 + +气泡只显示短状态: + +```text +已打开工作区。 +正在扫描文件。 +找到了 12 个相关文件。 +需要你确认修改计划。 +操作已完成。 +``` + +复杂结果不全部塞进气泡,当前通过独立 Agent 窗口展示。 + +当前独立 Agent 窗口可显示: + +- 当前工作区路径。 +- 文件树。 +- 搜索结果。 +- 已读取文件。 +- AI 分析结果。 +- 待确认操作计划。 +- 执行结果。 +- 最近操作历史。 + +## 12. 意图分发 + +后续建议新增: + +```cpp +enum class UserIntentType +{ + Chat, + Reminder, + Weather, + Workspace, + FileOperation, + LaunchApp +}; +``` + +意图原则: + +- `Reminder` 保持最高优先级。 +- 明确天气问答走 `Weather`。 +- 明确工作区、代码、项目分析走 `Workspace`。 +- 当前阶段,未明确“工作区/项目/代码”且没有工作区文件名特征的普通文件读写请求仍可走旧 `FileOperation`。 +- 明确应用名启动走 `LaunchApp`。 +- 其余走 `Chat` 或输入框联网模式。 + +不能用宽关键词简单吞掉所有请求: + +- “打开微信”应走 `LaunchApp`。 +- “打开文件夹”这种泛请求不走 `Workspace`;“打开 docs / 打开这个项目里的 docs”走 `Workspace`。 +- “搜索 Qt 最新版本”不应走工作区搜索。 +- “搜索这个项目里的 main”应走工作区搜索。 + +## 13. 迁移步骤 + +1. 新增 `src/workspace/`,不改旧 `src/fileops/`。 +2. CMake 登记 workspace 新文件。 +3. `PetWindow` 增加 `WorkspaceController` 成员。 +4. 新增工作区打开、扫描、搜索、读取入口。 +5. v1b 支持打开和定位;写操作计划和确认框从后续阶段开始。 +6. 新模块覆盖旧文件操作能力后,`handleFileOperationChatMessage()` 转发到 `WorkspaceController`。 +7. 确认无旧引用后删除 `src/fileops/`。 +8. 更新 CMake、README、测试文档。 + +删除旧文件前必须执行: + +```text +rg "FileOperationManager|FileSandbox|FileBackupManager|src/fileops|FileOperation" +``` + +确认没有有效代码引用后再删。 + +## 14. 日志要求 + +允许记录: + +- 打开工作区路径。 +- 扫描文件数量。 +- 执行的工具名称。 +- 操作是否成功。 +- 错误摘要。 + +禁止记录: + +- API Key。 +- Authorization Header。 +- 完整敏感文件内容。 +- `.env` 内容。 +- 密钥文件内容。 +- 用户完整隐私文本。 + +使用项目已有 `Logger`,不要新增零散调试输出。 + +## 15. 测试清单 + +### 工作区选择 + +- 选择普通项目目录。 +- 选择空目录。 +- 选择不存在目录。 +- 选择 `C:/Windows`,应拒绝。 +- 选择 `Program Files`,应拒绝。 +- 选择中文路径。 +- 选择包含空格的路径。 + +### 扫描 + +- 扫描普通 Qt 项目。 +- 扫描包含 build 目录的项目。 +- 扫描包含 `.git` 的项目。 +- 扫描大量文件目录。 +- 扫描中文文件名。 +- 扫描深层目录。 + +要求: + +- 不崩溃。 +- 不扫系统目录。 +- 不进入跳过目录。 +- 结果数量受限制。 + +### 搜索文件 + +- 搜索 `main`。 +- 搜索 `.cpp`。 +- 搜索 `README`。 +- 搜索不存在文件。 +- 搜索中文文件名。 + +要求: + +- 最多返回 30 条。 +- 显示相对路径。 +- 结果可用于后续“打开第几个”。 + +### 搜索文本 + +- 搜索 `class`。 +- 搜索 `include`。 +- 搜索中文关键词。 +- 搜索不存在关键词。 +- 搜索超大文件。 + +要求: + +- 显示路径、行号、片段。 +- 跳过二进制文件。 +- 跳过超大文件。 +- 不读取敏感文件。 + +### 读取文件 + +- 读取 `.cpp`。 +- 读取 `.md`。 +- 读取 `.json`。 +- 读取图片应拒绝。 +- 读取 `.exe` 应拒绝。 +- 读取 `.env` 应拒绝或强确认。 +- 读取工作区外文件应拒绝。 + +### 创建文件 + +- 在工作区创建 `test.txt`。 +- 创建已存在文件应拒绝。 +- 创建非法文件名应拒绝。 +- 创建 `.bat` 应拒绝。 +- 创建到工作区外应拒绝。 + +### 修改文件 + +- 修改普通 `.txt`。 +- 修改 `.cpp`。 +- 修改前生成备份。 +- 修改二进制文件应拒绝。 +- 修改敏感文件应拒绝。 + +要求: + +- 必须确认。 +- 必须备份。 +- 失败不能破坏原文件。 + +### 移动和重命名 + +- 移动普通文件。 +- 移动到已存在目标应拒绝。 +- 重命名普通文件。 +- 重命名为非法文件名应拒绝。 +- 移动到工作区外应拒绝。 + +### 删除到回收站 + +- 删除普通文件到回收站。 +- 删除文件夹到回收站。 +- 删除工作区外文件应拒绝。 +- 永久删除请求应拒绝。 +- 批量删除请求第一阶段应拒绝。 + +### 对话上下文 + +```text +用户:搜索 README +用户:打开第一个 + +用户:搜索 fileops +用户:读取第二个 + +用户:找一下包含 FileOperationManager 的文件 +用户:总结这些文件的作用 +``` + +要求: + +- 系统能记住上一轮搜索结果。 +- “第一个”“第二个”能正确解析。 +- 上下文过期后要求用户重新选择。 + +## 16. 静态推演要求 + +每完成一个阶段,需要记录: + +- 入口路径。 +- 业务模块调用链。 +- 路径校验点。 +- 用户确认点。 +- 失败回滚策略。 +- 不支持能力的拒绝话术。 +- 对提醒、天气、联网模式、应用启动的回归影响。 + +## 17. 最终验收标准 + +本轮重构完成后至少达到: + +1. 用户可以选择一个本地项目目录作为工作区。 +2. 程序可以扫描工作区并建立文件索引。 +3. 用户可以通过对话搜索文件。 +4. 用户可以通过对话读取和分析文本文件。 +5. 用户可以要求分析项目结构。 +6. 用户可以创建文本文件和文件夹。 +7. 用户可以移动、重命名、删除到回收站。 +8. 所有写操作必须展示计划并等待确认。 +9. 所有路径必须限制在工作区内。 +10. 不允许永久删除。 +11. 不允许执行脚本或命令。 +12. 不允许默认读取敏感文件。 +13. 旧文件操作功能被新 Workspace 系统替代。 +14. 项目可以由用户手动构建通过。 diff --git a/docs/archive/DesktopPet 本地工作区 Agent 开发文档_草案.md b/docs/archive/DesktopPet 本地工作区 Agent 开发文档_草案.md new file mode 100644 index 0000000..e944c55 --- /dev/null +++ b/docs/archive/DesktopPet 本地工作区 Agent 开发文档_草案.md @@ -0,0 +1,1741 @@ +# DesktopPet 本地工作区 Agent 开发文档 + +## 0. 文档目标 + +本次开发目标不是继续维护旧版“文件操作 v1”,而是将其重构升级为一个类似 Codex / Claude Code 思路的 **本地工作区 Agent**。 + +新的功能定位: + +> 用户选择一个本地工作区目录后,桌宠可以读取、搜索、分析该目录内的文件,并在用户确认后创建、修改、移动、重命名或删除文件。 + +注意: + +1. 不允许 AI 直接获得全盘文件权限。 +2. 不允许 AI 自由执行系统命令。 +3. 不允许永久删除文件。 +4. 不允许绕过用户确认修改本地文件。 +5. 所有写操作必须先生成计划,展示路径和风险,用户确认后才能执行。 +6. 当前阶段优先实现文件级工具,不做 shell 命令执行。 + +------ + +## 1. 当前项目背景 + +当前项目是 QtDesktopPet,技术栈为: + +- C++17 +- Qt 6 Widgets +- Qt 6 Network +- Qt 6 Multimedia +- CMake +- Windows 优先 + +当前项目已有: + +- 桌宠窗口 +- 动画状态 +- AI 对话 +- 天气 +- 提醒 +- 本地应用启动 +- 简单文件操作 v1 + +旧版文件操作 v1 只适合简单功能,例如: + +- 读取文本文件 +- 列出文件夹 +- 复制文件 +- 备份文件 +- 重命名文件 + +新的目标不是在旧版基础上打补丁,而是设计一个独立的工作区系统。 + +------ + +## 2. 总体开发原则 + +### 2.1 先新增,后替换 + +不要一上来删除旧文件。 + +推荐流程: + +```text +新增 workspace 模块 + ↓ +实现只读能力 + ↓ +实现写入计划和确认 + ↓ +接入 PetWindow + ↓ +确认能编译运行 + ↓ +替换旧 FileOperationManager + ↓ +最后删除旧文件操作模块 +``` + +### 2.2 PetWindow 不承载业务逻辑 + +`PetWindow` 只负责 UI 展示和用户交互,不应该直接处理复杂文件逻辑。 + +新的业务逻辑应放到: + +```text +src/workspace/ +src/assistant/ +``` + +`PetWindow` 只调用控制器,例如: + +```cpp +m_workspaceController->handleUserMessage(message); +``` + +### 2.3 AI 不直接操作文件 + +AI 不能直接拿到文件系统权限。 + +正确流程是: + +```text +用户输入 + ↓ +WorkspaceAgent 分析任务 + ↓ +生成文件操作计划 + ↓ +展示计划 + ↓ +用户确认 + ↓ +WorkspaceTool 执行 + ↓ +返回结果 +``` + +------ + +## 3. 功能范围 + +## 3.1 第一阶段必须实现 + +第一阶段目标:**只读工作区分析 + 安全文件操作基础能力**。 + +必须支持: + +```text +选择工作区 +显示当前工作区 +扫描工作区文件树 +搜索文件名 +搜索文本内容 +读取文本文件 +分析项目结构 +总结文件内容 +打开文件 +打开文件夹 +显示文件所在位置 +创建文本文件 +创建文件夹 +修改文本文件 +复制文件 +移动文件 +重命名文件 +删除到回收站 +记录最近操作 +``` + +其中: + +- 读操作可以直接执行。 +- 打开文件建议确认。 +- 创建、修改、移动、重命名、删除必须确认。 +- 删除只能移动到回收站。 +- 修改文件必须备份或使用安全写入策略。 + +------ + +## 3.2 第一阶段禁止实现 + +以下功能第一阶段不要做: + +```text +全盘搜索 +永久删除 +批量删除 +批量移动 +批量重命名 +执行 shell 命令 +运行脚本 +运行 exe +自动 git commit +自动安装依赖 +自动联网下载并执行文件 +读取系统敏感目录 +读取工作区外文件 +``` + +后续如果需要运行命令,应作为第二阶段或第三阶段功能,并单独设计命令白名单和权限系统。 + +------ + +# 4. 新模块目录结构 + +新增目录: + +```text +src/workspace/ +├── WorkspaceTypes.h +├── WorkspaceSession.h +├── WorkspaceSession.cpp +├── WorkspaceIndex.h +├── WorkspaceIndex.cpp +├── WorkspaceScanner.h +├── WorkspaceScanner.cpp +├── WorkspaceAccessPolicy.h +├── WorkspaceAccessPolicy.cpp +├── WorkspaceTool.h +├── WorkspaceToolRegistry.h +├── WorkspaceToolRegistry.cpp +├── WorkspaceAgent.h +├── WorkspaceAgent.cpp +├── WorkspaceController.h +├── WorkspaceController.cpp +├── WorkspaceHistory.h +├── WorkspaceHistory.cpp +└── tools/ + ├── ListFilesTool.h + ├── ListFilesTool.cpp + ├── ReadFileTool.h + ├── ReadFileTool.cpp + ├── SearchFilesTool.h + ├── SearchFilesTool.cpp + ├── SearchTextTool.h + ├── SearchTextTool.cpp + ├── OpenFileTool.h + ├── OpenFileTool.cpp + ├── CreateFileTool.h + ├── CreateFileTool.cpp + ├── CreateDirectoryTool.h + ├── CreateDirectoryTool.cpp + ├── WriteFileTool.h + ├── WriteFileTool.cpp + ├── CopyFileTool.h + ├── CopyFileTool.cpp + ├── MoveFileTool.h + ├── MoveFileTool.cpp + ├── RenameFileTool.h + ├── RenameFileTool.cpp + ├── DeleteToTrashTool.h + └── DeleteToTrashTool.cpp +``` + +旧目录 `src/fileops/` 暂时不要删除。等新模块完全接入后,再移除旧模块。 + +------ + +# 5. 核心类型设计 + +## 5.1 WorkspacePermissionMode + +```cpp +enum class WorkspacePermissionMode +{ + ReadOnly, + ConfirmBeforeWrite, + Advanced +}; +``` + +说明: + +- `ReadOnly`:只能读、搜索、分析。 +- `ConfirmBeforeWrite`:允许写,但每次写操作都必须确认。 +- `Advanced`:预留,不在第一阶段实现自动写入。 + +第一阶段默认使用: + +```cpp +WorkspacePermissionMode::ConfirmBeforeWrite +``` + +但所有写操作仍然必须确认。 + +------ + +## 5.2 WorkspaceToolRisk + +```cpp +enum class WorkspaceToolRisk +{ + ReadOnly, + Low, + Medium, + High, + Dangerous +}; +``` + +风险等级说明: + +```text +ReadOnly: + 搜索、列目录、读取普通文本文件。 + +Low: + 打开文件夹、显示文件所在位置。 + +Medium: + 打开普通文件、创建文件、创建文件夹、复制文件。 + +High: + 修改文件、移动文件、重命名文件、删除到回收站。 + +Dangerous: + 永久删除、执行命令、运行脚本、修改系统目录。 +``` + +第一阶段遇到 `Dangerous` 直接拒绝。 + +------ + +## 5.3 WorkspaceFileInfo + +```cpp +struct WorkspaceFileInfo +{ + QString relativePath; + QString absolutePath; + QString fileName; + QString suffix; + qint64 sizeBytes = 0; + QDateTime lastModified; + bool isDirectory = false; + bool isTextFile = false; + bool isHidden = false; +}; +``` + +用途: + +- 文件树展示 +- 文件名搜索 +- 后缀过滤 +- 快速判断是否可读 + +------ + +## 5.4 WorkspaceActionPlan + +```cpp +enum class WorkspaceActionType +{ + Unknown, + + ListFiles, + SearchFiles, + SearchText, + ReadFile, + + OpenFile, + OpenDirectory, + RevealInExplorer, + + CreateFile, + CreateDirectory, + WriteFile, + + CopyFile, + MoveFile, + RenameFile, + DeleteToTrash +}; + +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; +}; +``` + +注意: + +- 所有路径都必须是工作区内路径。 +- `sourcePath` 和 `targetPath` 必须经过 `WorkspaceAccessPolicy` 校验。 +- 写操作默认 `requiresConfirmation = true`。 +- 第一阶段禁止覆盖文件,所以 `allowOverwrite` 默认必须是 false。 + +------ + +## 5.5 WorkspaceToolResult + +```cpp +struct WorkspaceToolResult +{ + bool success = false; + QString message; + QString errorMessage; + QString outputText; + QString targetPath; + QVector files; +}; +``` + +------ + +# 6. WorkspaceAccessPolicy 设计 + +这是最重要的安全模块。 + +## 6.1 职责 + +`WorkspaceAccessPolicy` 负责: + +```text +路径标准化 +判断路径是否在工作区内 +阻止 ../ 跳出工作区 +阻止符号链接跳出工作区 +阻止系统目录 +阻止危险文件类型 +阻止敏感文件默认读取 +限制写入行为 +限制删除行为 +``` + +------ + +## 6.2 必须实现的方法 + +```cpp +class WorkspaceAccessPolicy +{ +public: + explicit WorkspaceAccessPolicy(const QString &workspaceRoot); + + QString workspaceRoot() const; + + QString normalizePath(const QString &path) const; + QString toAbsolutePath(const QString &relativeOrAbsolutePath) const; + QString toRelativePath(const QString &absolutePath) const; + + bool isInsideWorkspace(const QString &path, QString *errorMessage = nullptr) const; + bool hasSymlinkSegment(const QString &path, QString *errorMessage = nullptr) const; + + bool isSystemPath(const QString &path) const; + bool isDangerousSuffix(const QString &path) const; + bool isSensitiveFile(const QString &path) const; + bool isSupportedTextFile(const QString &path) const; + + bool canReadFile(const QString &path, QString *errorMessage = nullptr) const; + bool canReadDirectory(const QString &path, QString *errorMessage = nullptr) const; + bool canCreateFile(const QString &path, QString *errorMessage = nullptr) const; + bool canCreateDirectory(const QString &path, QString *errorMessage = nullptr) const; + bool canWriteFile(const QString &path, QString *errorMessage = nullptr) const; + bool canMoveFile(const QString &sourcePath, const QString &targetPath, QString *errorMessage = nullptr) const; + bool canDeleteToTrash(const QString &path, QString *errorMessage = nullptr) const; +}; +``` + +------ + +## 6.3 路径限制 + +所有操作必须满足: + +```text +目标路径在 workspaceRoot 内 +不能包含符号链接路径段 +不能是 Windows 系统目录 +不能是 Program Files +不能是 ProgramData +不能是回收站目录 +不能是工作区外路径 +``` + +禁止通过以下方式绕过: + +```text +../ +..\ +符号链接 +快捷方式跳转 +相对路径混淆 +大小写混淆 +``` + +------ + +## 6.4 危险后缀 + +第一阶段默认禁止写入、打开或执行以下文件: + +```text +.exe +.bat +.cmd +.ps1 +.vbs +.msi +.scr +.reg +.com +.pif +.lnk +``` + +其中 `.lnk` 不要直接打开,因为它可能指向工作区外路径或脚本。 + +------ + +## 6.5 敏感文件 + +以下文件读取时必须强确认,第一阶段可以先默认拒绝: + +```text +.env +*.pem +*.key +id_rsa +id_ed25519 +*.pfx +*.p12 +credentials.json +secrets.json +token.json +``` + +如果文件名包含以下关键词,也视为敏感: + +```text +secret +token +password +credential +private +apikey +api_key +``` + +------ + +# 7. WorkspaceScanner 和 WorkspaceIndex + +## 7.1 WorkspaceScanner + +负责扫描工作区。 + +建议使用: + +```cpp +QDirIterator +``` + +扫描规则: + +```text +最大递归深度:8 +最大文件数量:5000 +跳过隐藏目录 +跳过 build 目录 +跳过 .git 目录 +跳过 node_modules +跳过 dist +跳过 release_packages +跳过 .vs +跳过 .vscode 可选 +``` + +建议跳过目录: + +```text +.git +.vs +.idea +build +cmake-build-debug +cmake-build-release +node_modules +dist +release +release_packages +logs +``` + +------ + +## 7.2 WorkspaceIndex + +负责保存扫描结果。 + +```cpp +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; + +private: + QVector m_files; +}; +``` + +第一版不需要数据库,不需要向量索引,内存索引即可。 + +------ + +# 8. 工具系统设计 + +## 8.1 WorkspaceTool 抽象 + +```cpp +class WorkspaceTool +{ +public: + virtual ~WorkspaceTool() = default; + + virtual QString name() const = 0; + virtual QString displayName() const = 0; + virtual WorkspaceToolRisk risk() const = 0; + + virtual WorkspaceToolResult run(const WorkspaceActionPlan &plan) = 0; +}; +``` + +所有工具必须通过 `WorkspaceAccessPolicy` 校验路径。 + +------ + +## 8.2 ListFilesTool + +功能: + +```text +列出工作区某个目录下的文件和文件夹。 +``` + +限制: + +```text +只允许工作区内目录 +最多显示 200 项 +不递归或浅递归 +``` + +------ + +## 8.3 ReadFileTool + +功能: + +```text +读取文本文件。 +``` + +限制: + +```text +只读文本文件 +最大读取 128KB +超出部分截断 +敏感文件默认拒绝或强确认 +不读取二进制文件 +``` + +支持后缀: + +```text +.cpp +.h +.hpp +.c +cc +py +js +ts +json +md +txt +ini +xml +yaml +yml +cmake +pro +css +html +``` + +------ + +## 8.4 SearchFilesTool + +功能: + +```text +按文件名搜索。 +``` + +限制: + +```text +只搜索当前工作区索引 +最多返回 30 个结果 +按相关性和最近修改时间排序 +``` + +------ + +## 8.5 SearchTextTool + +功能: + +```text +在文本文件中搜索关键词。 +``` + +限制: + +```text +最多扫描 1000 个文本文件 +单文件最大读取 128KB +最多返回 50 条匹配 +每条匹配显示文件路径、行号、片段 +``` + +------ + +## 8.6 OpenFileTool + +功能: + +```text +用系统默认程序打开文件。 +``` + +实现建议: + +```cpp +QDesktopServices::openUrl(QUrl::fromLocalFile(path)); +``` + +限制: + +```text +只能打开工作区内文件 +危险后缀拒绝 +敏感文件需要确认 +``` + +打开文件夹同理。 + +------ + +## 8.7 RevealInExplorer + +功能: + +```text +在资源管理器中显示文件所在位置。 +``` + +Windows 可使用: + +```cpp +explorer.exe /select,"path" +``` + +但第一阶段如果不想涉及命令,可以先打开所在文件夹。 + +------ + +## 8.8 CreateFileTool + +功能: + +```text +创建文本文件。 +``` + +限制: + +```text +只能在工作区内创建 +目标文件不能已存在 +文件名必须合法 +危险后缀拒绝 +写操作必须确认 +``` + +建议使用 `QSaveFile` 或 `QFile` 创建。 + +------ + +## 8.9 WriteFileTool + +功能: + +```text +写入文本文件。 +``` + +第一阶段建议只支持: + +```text +新建文件写入 +或 +修改已有文本文件,但写入前自动备份 +``` + +要求: + +```text +写入前必须确认 +写入前创建备份 +禁止覆盖二进制文件 +禁止写敏感文件 +禁止写危险后缀 +``` + +备份目录建议: + +```text +/.desktoppet/backups/ +``` + +备份文件名建议: + +```text +relative_path.replace("/", "__") + "." + timestamp + ".bak" +``` + +------ + +## 8.10 CopyFileTool + +限制: + +```text +只能复制单文件 +不能覆盖目标文件 +源和目标都必须在工作区内 +写操作必须确认 +``` + +------ + +## 8.11 MoveFileTool + +限制: + +```text +只能移动单文件 +不能覆盖目标文件 +源和目标都必须在工作区内 +写操作必须确认 +``` + +------ + +## 8.12 RenameFileTool + +限制: + +```text +只能在同目录内重命名 +不能覆盖目标文件 +文件名不能包含路径分隔符 +写操作必须确认 +``` + +------ + +## 8.13 DeleteToTrashTool + +功能: + +```text +移动到系统回收站。 +``` + +实现建议: + +```cpp +QFile file(path); +file.moveToTrash(); +``` + +或根据 Qt 版本使用对应的 `moveToTrash` 方法。 + +限制: + +```text +只允许工作区内文件 +不做永久删除 +删除文件夹需要强确认 +删除敏感文件需要强确认 +删除危险后缀文件需要强确认 +``` + +第一阶段禁止批量删除。 + +------ + +# 9. WorkspaceAgent 设计 + +## 9.1 第一版采用半自动 Agent + +第一版不要急着实现复杂 function calling。 + +建议采用半自动流程: + +```text +用户输入 + ↓ +WorkspaceController 判断是否是工作区任务 + ↓ +程序先根据关键词和工作区索引检索相关文件 + ↓ +读取少量相关文件 + ↓ +把文件树摘要、相关文件内容、用户问题交给模型 + ↓ +模型返回分析结果或修改建议 + ↓ +涉及写操作时生成 WorkspaceActionPlan + ↓ +用户确认 + ↓ +执行工具 +``` + +这样比完全由模型自由调用工具更容易控制。 + +------ + +## 9.2 后续可升级为工具循环 + +未来可以升级为: + +```text +model + ↓ +tool call + ↓ +tool result + ↓ +model + ↓ +tool call + ↓ +... +``` + +但第一版不强制做。 + +------ + +## 9.3 工作区模式系统提示词 + +工作区模式不要使用普通桌宠聊天提示词。 + +新增工作区专用提示词: + +```text +你是 QtDesktopPet 的本地工作区助手。 + +你只能基于用户授权的工作区文件进行分析。 +你不能假设自己看过未读取的文件。 +你不能要求访问工作区外路径。 +你不能执行系统命令。 +你不能生成危险操作指令。 +涉及创建、修改、移动、重命名、删除文件时,必须先生成清晰的操作计划,等待用户确认。 +涉及敏感文件、密钥、凭证时,必须提醒风险。 +回答代码问题时要引用具体文件路径。 +当信息不足时,说明还需要读取哪些文件。 +``` + +------ + +# 10. WorkspaceController 设计 + +`WorkspaceController` 负责连接 UI 和工作区 Agent。 + +建议接口: + +```cpp +class WorkspaceController +{ +public: + explicit WorkspaceController(); + + bool hasWorkspace() const; + QString workspaceRoot() const; + + bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr); + void closeWorkspace(); + + WorkspaceToolResult scanWorkspace(); + + WorkspaceToolResult handleMessage(const QString &message); + + bool hasPendingPlan() const; + WorkspaceActionPlan pendingPlan() const; + WorkspaceToolResult confirmPendingPlan(); + void cancelPendingPlan(); + +private: + WorkspaceSession m_session; + WorkspaceAgent m_agent; + std::optional m_pendingPlan; +}; +``` + +说明: + +- `handleMessage` 不直接执行高风险写操作。 +- 如果需要确认,则保存到 `m_pendingPlan`。 +- UI 调用 `confirmPendingPlan` 后才执行。 + +------ + +# 11. UI 接入建议 + +## 11.1 PetWindow 入口 + +在聊天系统中增加: + +```text +工作区模式 +``` + +可以通过以下方式触发: + +```text +打开工作区 +选择工作区 +分析这个项目 +搜索当前工作区 +``` + +如果用户没有选择工作区,则提示选择目录。 + +第一版可以直接用: + +```cpp +QFileDialog::getExistingDirectory(...) +``` + +让用户选择工作区。 + +------ + +## 11.2 气泡显示策略 + +桌宠气泡只显示简短状态: + +```text +已打开工作区。 +正在扫描文件。 +找到了 12 个相关文件。 +需要你确认修改计划。 +操作已完成。 +``` + +复杂结果不要全部塞进气泡。 + +------ + +## 11.3 工作区面板 + +建议新增: + +```text +WorkspacePanel +``` + +第一版可以晚点做,先用对话框和聊天历史展示。 + +最终面板建议显示: + +```text +当前工作区路径 +文件树 +搜索结果 +读取过的文件 +AI 分析结果 +待确认操作计划 +执行结果 +最近操作历史 +``` + +------ + +# 12. 和旧文件操作的迁移关系 + +当前旧入口可能是: + +```cpp +handleFileOperationChatMessage(...) +``` + +新方案中应改为: + +```cpp +handleWorkspaceMessage(...) +``` + +迁移步骤: + +```text +第一步: + 新增 workspace 模块,不改旧 fileops。 + +第二步: + CMakeLists.txt 加入 workspace 新文件。 + +第三步: + 在 PetWindow 中新增 WorkspaceController 成员。 + +第四步: + IntentRouter 中把文件相关意图改为 Workspace。 + +第五步: + handleFileOperationChatMessage 内部临时转发到 WorkspaceController。 + +第六步: + 确认新模块可用后,删除旧 FileOperationManager 相关调用。 + +第七步: + 删除旧 src/fileops 旧文件并更新 CMakeLists.txt。 +``` + +注意:删除旧文件前必须确认没有任何 include 或成员变量引用旧类。 + +------ + +# 13. IntentRouter 调整 + +当前 `IntentRouter` 只按关键词判断文件操作,后续应改成更宽泛的工作区意图。 + +建议: + +```cpp +enum class UserIntentType +{ + Chat, + Reminder, + Weather, + Workspace, + LaunchApp +}; +``` + +工作区关键词: + +```text +文件 +文件夹 +目录 +项目 +代码 +源码 +工作区 +分析这个项目 +搜索 +读取 +打开文件 +创建文件 +修改文件 +删除文件 +移动文件 +重命名 +``` + +注意: + +```text +打开微信 +打开浏览器 +打开 QQ +``` + +这类应该走 `LaunchApp`,不要误判成 Workspace。 + +建议优先级: + +```text +Reminder +Weather +Workspace +LaunchApp +Chat +``` + +但如果文本中出现明确应用名,可优先 LaunchApp。 + +------ + +# 14. CMake 修改计划 + +新增 workspace 文件后,需要在 `CMakeLists.txt` 中加入: + +```text +src/workspace/WorkspaceTypes.h +src/workspace/WorkspaceSession.h +src/workspace/WorkspaceSession.cpp +src/workspace/WorkspaceIndex.h +src/workspace/WorkspaceIndex.cpp +src/workspace/WorkspaceScanner.h +src/workspace/WorkspaceScanner.cpp +src/workspace/WorkspaceAccessPolicy.h +src/workspace/WorkspaceAccessPolicy.cpp +src/workspace/WorkspaceTool.h +src/workspace/WorkspaceToolRegistry.h +src/workspace/WorkspaceToolRegistry.cpp +src/workspace/WorkspaceAgent.h +src/workspace/WorkspaceAgent.cpp +src/workspace/WorkspaceController.h +src/workspace/WorkspaceController.cpp +src/workspace/WorkspaceHistory.h +src/workspace/WorkspaceHistory.cpp +src/workspace/tools/ListFilesTool.h +src/workspace/tools/ListFilesTool.cpp +src/workspace/tools/ReadFileTool.h +src/workspace/tools/ReadFileTool.cpp +src/workspace/tools/SearchFilesTool.h +src/workspace/tools/SearchFilesTool.cpp +src/workspace/tools/SearchTextTool.h +src/workspace/tools/SearchTextTool.cpp +src/workspace/tools/OpenFileTool.h +src/workspace/tools/OpenFileTool.cpp +src/workspace/tools/CreateFileTool.h +src/workspace/tools/CreateFileTool.cpp +src/workspace/tools/CreateDirectoryTool.h +src/workspace/tools/CreateDirectoryTool.cpp +src/workspace/tools/WriteFileTool.h +src/workspace/tools/WriteFileTool.cpp +src/workspace/tools/CopyFileTool.h +src/workspace/tools/CopyFileTool.cpp +src/workspace/tools/MoveFileTool.h +src/workspace/tools/MoveFileTool.cpp +src/workspace/tools/RenameFileTool.h +src/workspace/tools/RenameFileTool.cpp +src/workspace/tools/DeleteToTrashTool.h +src/workspace/tools/DeleteToTrashTool.cpp +``` + +注意: + +当前项目 `CMAKE_AUTOMOC` 是关闭的。如果新增类不使用 `Q_OBJECT`,可以继续保持现状。如果新增 QWidget 并需要 signals / slots,则需要重新考虑 `CMAKE_AUTOMOC`。 + +第一阶段建议 workspace 业务类都不要使用 `Q_OBJECT`,避免牵连构建配置。 + +------ + +# 15. 日志要求 + +不要记录敏感内容。 + +允许记录: + +```text +打开工作区路径 +扫描文件数量 +执行的工具名称 +操作是否成功 +错误摘要 +``` + +禁止记录: + +```text +API Key +Authorization header +完整敏感文件内容 +.env 内容 +密钥文件内容 +用户完整隐私文本 +``` + +使用项目已有 Logger,不要使用 `OutputDebugStringA`。 + +------ + +# 16. 错误处理 + +所有工具错误都必须返回 `WorkspaceToolResult`,不要直接弹窗。 + +错误示例: + +```text +路径不在当前工作区内。 +目标文件已存在。 +该文件类型不允许写入。 +该文件可能包含敏感信息,已拒绝读取。 +删除操作已取消。 +无法移动到回收站。 +``` + +UI 层负责展示错误。 + +------ + +# 17. 测试清单 + +## 17.1 工作区选择 + +测试: + +```text +选择普通项目目录 +选择空目录 +选择不存在目录 +选择 C:/Windows,应该拒绝 +选择 Program Files,应该拒绝 +选择包含中文路径的目录 +选择包含空格路径的目录 +``` + +------ + +## 17.2 扫描 + +测试: + +```text +扫描普通 Qt 项目 +扫描包含 build 目录的项目 +扫描包含 .git 的项目 +扫描大量文件目录 +扫描中文文件名 +扫描深层目录 +``` + +要求: + +```text +不崩溃 +不扫系统目录 +不进入跳过目录 +结果数量受限制 +``` + +------ + +## 17.3 搜索文件 + +测试: + +```text +搜索 main +搜索 .cpp +搜索 README +搜索不存在文件 +搜索中文文件名 +``` + +要求: + +```text +最多返回 30 条 +显示相对路径 +结果可用于后续“打开第几个” +``` + +------ + +## 17.4 搜索文本 + +测试: + +```text +搜索 class +搜索 include +搜索中文关键词 +搜索不存在关键词 +搜索超大文件 +``` + +要求: + +```text +显示路径、行号、片段 +跳过二进制文件 +跳过超大文件 +不读取敏感文件 +``` + +------ + +## 17.5 读取文件 + +测试: + +```text +读取 .cpp +读取 .md +读取 .json +读取图片,应该拒绝 +读取 exe,应该拒绝 +读取 .env,应该拒绝或强确认 +读取工作区外文件,应该拒绝 +``` + +------ + +## 17.6 创建文件 + +测试: + +```text +在工作区创建 test.txt +创建已存在文件,应该拒绝 +创建非法文件名,应该拒绝 +创建 .bat,应该拒绝 +创建到工作区外,应该拒绝 +``` + +------ + +## 17.7 修改文件 + +测试: + +```text +修改普通 txt +修改 cpp +修改已存在文件前生成备份 +修改二进制文件,应该拒绝 +修改敏感文件,应该拒绝 +``` + +要求: + +```text +必须确认 +必须备份 +失败不能破坏原文件 +``` + +------ + +## 17.8 移动和重命名 + +测试: + +```text +移动普通文件 +移动到已存在目标,应该拒绝 +重命名普通文件 +重命名为非法文件名,应该拒绝 +移动到工作区外,应该拒绝 +``` + +------ + +## 17.9 删除到回收站 + +测试: + +```text +删除普通文件到回收站 +删除文件夹到回收站 +删除工作区外文件,应该拒绝 +永久删除请求,应该拒绝 +批量删除请求,第一阶段应该拒绝 +``` + +------ + +## 17.10 对话上下文 + +测试: + +```text +用户:搜索 README +用户:打开第一个 + +用户:搜索 fileops +用户:读取第二个 + +用户:找一下包含 FileOperationManager 的文件 +用户:总结这些文件的作用 +``` + +要求: + +```text +系统能记住上一轮搜索结果 +“第一个”“第二个”能正确解析 +上下文过期后要求用户重新选择 +``` + +------ + +# 18. 推荐开发任务拆分 + +## 任务 1:新增类型和安全策略 + +目标: + +```text +新增 WorkspaceTypes.h +新增 WorkspaceAccessPolicy +不接 UI +保证项目可编译 +``` + +验收: + +```text +能判断路径是否在工作区内 +能拒绝系统目录 +能拒绝符号链接 +能拒绝危险后缀 +``` + +------ + +## 任务 2:新增扫描和索引 + +目标: + +```text +实现 WorkspaceScanner +实现 WorkspaceIndex +支持扫描工作区文件树 +``` + +验收: + +```text +可以扫描普通项目 +可以跳过 build/.git/node_modules +可以按文件名搜索 +``` + +------ + +## 任务 3:新增只读工具 + +目标: + +```text +ListFilesTool +ReadFileTool +SearchFilesTool +SearchTextTool +``` + +验收: + +```text +能列目录 +能读文本文件 +能搜索文件名 +能搜索文本内容 +不读取二进制和敏感文件 +``` + +------ + +## 任务 4:新增打开工具 + +目标: + +```text +OpenFileTool +OpenDirectory +RevealInExplorer +``` + +验收: + +```text +能打开普通文件 +能打开文件夹 +危险后缀拒绝 +工作区外路径拒绝 +``` + +------ + +## 任务 5:新增写入工具 + +目标: + +```text +CreateFileTool +CreateDirectoryTool +WriteFileTool +CopyFileTool +MoveFileTool +RenameFileTool +DeleteToTrashTool +``` + +验收: + +```text +所有写操作必须要求 confirmed +未确认时拒绝执行 +目标已存在时拒绝覆盖 +删除只进回收站 +写入前备份 +``` + +------ + +## 任务 6:新增 WorkspaceController + +目标: + +```text +统一管理工作区、索引、工具、待确认计划 +``` + +验收: + +```text +能 openWorkspace +能 scanWorkspace +能 handleMessage +能保存 pendingPlan +能确认执行 pendingPlan +``` + +------ + +## 任务 7:接入 PetWindow + +目标: + +```text +PetWindow 增加 WorkspaceController +聊天消息中的工作区相关请求转发给 WorkspaceController +``` + +验收: + +```text +用户可以选择工作区 +可以搜索文件 +可以读取文件 +写操作会弹确认 +确认后执行 +``` + +------ + +## 任务 8:替换旧 fileops + +目标: + +```text +旧 handleFileOperationChatMessage 改为调用 workspace +删除旧 FileOperationManager 依赖 +更新 CMake +``` + +验收: + +```text +项目无旧 FileOperationManager 引用 +项目能编译 +README 更新为“本地工作区助手” +``` + +------ + +# 19. README 更新要求 + +README 中文件操作部分应改为: + +```text +本地工作区助手: + +- 用户选择一个工作区目录后,桌宠可以读取、搜索和分析其中的文本文件。 +- 支持文件名搜索、文本搜索、文件读取、项目结构分析。 +- 支持创建、修改、移动、重命名和删除到回收站,但所有写操作都需要用户确认。 +- 不支持永久删除。 +- 不支持执行脚本或系统命令。 +- 不允许访问工作区外路径。 +- 敏感文件和危险后缀会被拦截或要求强确认。 +``` + +------ + +# 20. 最终验收标准 + +本轮重构完成后,至少应达到: + +```text +1. 用户可以选择一个本地项目目录作为工作区。 +2. 程序可以扫描工作区并建立文件索引。 +3. 用户可以通过对话搜索文件。 +4. 用户可以通过对话读取和分析文本文件。 +5. 用户可以要求分析项目结构。 +6. 用户可以创建文本文件和文件夹。 +7. 用户可以移动、重命名、删除到回收站。 +8. 所有写操作必须展示计划并等待确认。 +9. 所有路径必须限制在工作区内。 +10. 不允许永久删除。 +11. 不允许执行脚本或命令。 +12. 不允许默认读取敏感文件。 +13. 旧文件操作功能被新 Workspace 系统替代。 +14. 项目可以正常编译。 +``` + +------ + +# 21. 特别注意 + +这是一次架构升级,不是简单加几个文件函数。 + +不要把功能写成: + +```text +AI 说删就删 +AI 说改就改 +AI 说运行就运行 +``` + +必须写成: + +```text +AI 提出计划 +程序校验路径和风险 +用户确认 +程序执行白名单工具 +``` + +这个边界必须守住。 + +最终目标是: + +> 让 DesktopPet 具备“读工作区、理解工作区、规划修改、确认后执行”的能力,形成桌宠形态的本地工作区 Agent。 \ No newline at end of file diff --git a/docs/archive/QtDesktopPet_后续功能规划与结构审查_旧版.md b/docs/archive/QtDesktopPet_后续功能规划与结构审查_旧版.md new file mode 100644 index 0000000..c2999e1 --- /dev/null +++ b/docs/archive/QtDesktopPet_后续功能规划与结构审查_旧版.md @@ -0,0 +1,1214 @@ +# QtDesktopPet 后续功能开发与当前结构审查文档 + +> 适用仓库:`https://git.emoera.com/Make/Qt_DesktopPet` +> 目的:在继续新增功能前,先收口当前结构风险,并为后续功能扩展确定统一入口和实现边界。 +> 注意:本文档不是让 Codex 一次性实现所有功能,而是作为后续开发路线和代码审查依据。 + +--- + +## 1. 当前项目状态概览 + +根据当前 README 和仓库结构,项目已经具备较完整的桌宠应用基础: + +- 透明无边框桌宠窗口 +- 鼠标拖动、置顶、右键菜单 +- 托盘显示、隐藏、退出 +- 单实例限制 +- PNG 序列帧动画播放 +- `idle / talk / think / sleep / happy / drag / error` 多状态动画 +- 隐藏时暂停动画,显示时恢复 +- 窗口位置、置顶、缩放、性能设置保存 +- 文件日志和日志轮转 +- AI Provider 分组配置 +- OpenAI Compatible 聊天请求 +- Google Gemini 原生聊天请求 +- SSE 流式输出 +- AI 回复气泡 +- 聊天输入框 +- 对话历史面板 +- 本地历史保存和历史上限 +- AI 请求取消和对话清空 +- 角色文件夹导入和角色切换 +- 删除用户导入角色 +- 本地一次性/重复提醒、提醒列表、取消提醒和到点通知 +- 内置/用户提醒音效切换、导入、删除和试听 +- 天气查询、默认城市、公网 IP 定位兜底和多候选提示 +- 本地文件操作安全入口:读取文本、列目录、复制、备份、重命名 +- 联网模式:输入框开关、OpenAI/Gemini 原生联网、DeepSeek/Custom 不支持提示 +- 本地应用启动:登记应用、开始菜单 / App Paths 发现、手选 `.exe` 和二次确认 +- Windows 打包脚本和 Inno Setup 安装器脚本 +- Release exe 双击不弹控制台窗口 + +项目已经从早期 MVP 进入到“可扩展桌面应用原型”阶段,可以开始规划工具能力扩展。 + +但是,在正式加入定时提醒、天气、本地文件操作、联网模式之前,建议先做一轮结构收口。 + +--- + +## 2. 当前需要优先审查或优化的地方 + +### 2.1 审查角色切换是否保存后立即生效 + +当前已经支持角色导入和角色切换。经当前测试确认: + +```text +设置页选择新角色 +保存设置 +当前桌宠窗口是否立即重新加载新角色包 +``` + +结论:保存后会立即切换,不依赖重启。 + +当前代码路径: + +```text +PetWindow::openSettingsDialog() +↓ +applyAppConfig(dialog.appConfig()) +↓ +PetWindow::applyAppConfig() 比较旧 characterId 与新 characterId +↓ +characterId 变化时停止当前动画并调用 loadCharacterPackage() +↓ +loadCharacterPackage() 重新构建动画状态并从 idle 状态开始播放 +``` + +后续不需要围绕 2.1 做修复,只需要保留为回归检查项: + +```text +设置页切换角色后点击保存,桌宠应立即显示新角色 +不需要关闭或重启程序 +切换后动画状态应可继续正常播放 +``` + +--- + +### 2.2 不要继续向 PetWindow 堆功能 + +目前 `PetWindow` 已经承担了过多职责,后续不能继续把所有新功能塞进去。 + +`PetWindow` 应该主要负责: + +```text +窗口显示 +拖动 +菜单 +子组件组织 +基础 UI 信号转发 +``` + +不建议继续负责: + +```text +提醒解析 +天气查询 +联网模式 +本地文件读写 +AI 工具调度 +复杂业务状态管理 +``` + +后续应逐步拆出: + +```text +ChatController +ReminderController +WeatherController +FileOperationController +ToolCommandDispatcher +``` + +即使暂时不大规模重构,也至少要为后续功能增加统一调度层,不要直接把新逻辑写进 `PetWindow::submitChatMessage()`。 + +--- + +### 2.3 新增 IntentRouter / CommandDispatcher + +后续功能包括: + +```text +定时提醒 +天气查询 +本地文件操作 +联网模式 +本地应用启动 +普通 AI 对话 +``` + +这些都来自用户输入框。如果没有统一入口,逻辑会混乱。 + +可拆成两个模块: + +```text +src/intent/ + ├── IntentTypes.h + ├── IntentRouter.h + └── IntentRouter.cpp + +src/command/ + ├── CommandDispatcher.h + └── CommandDispatcher.cpp +``` + +当前阶段已选择合并为一个轻量模块: + +```text +src/assistant/ + ├── UserIntent.h + ├── IntentRouter.h + ├── IntentRouter.cpp + ├── CommandDispatcher.h + └── CommandDispatcher.cpp +``` + +当前意图枚举: + +```cpp +enum class UserIntentType +{ + Chat, + Reminder, + Weather, + FileOperation, + LaunchApp +}; +``` + +建议入口流程: + +```text +用户输入 +↓ +IntentRouter 判断意图 +↓ +CommandDispatcher 分发 +↓ +ReminderManager / WeatherManager / FileOperationManager / WebChatManager / AppLaunchManager / ConversationManager +``` + +第一版意图识别不需要复杂,规则优先即可。 + +--- + +### 2.4 意图优先级建议 + +多个意图可能同时出现,需要固定优先级。 + +推荐: + +```text +Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat +``` + +原因: + +```text +“明天早上提醒我看天气” +虽然包含“天气”,但本质是提醒。 + +“把天气截图保存到桌面” +可能是文件操作,而不是单纯天气查询。 + +“搜索一下明天天气” +新版不再作为独立搜索工具处理;如果已有 WeatherTool,应优先走天气工具,否则按普通聊天或输入框联网开关进入 Chat/WebChat。 +``` + +--- + +### 2.5 检查 CMAKE_AUTOMOC 设置 + +如果后续新增的 Manager 使用 `QObject`、`signals`、`slots`,建议开启: + +```cmake +set(CMAKE_AUTOMOC ON) +``` + +如果继续保持 `CMAKE_AUTOMOC OFF`,则后续模块应避免 `Q_OBJECT`,全部使用普通 C++ 回调或现有信号体系。 + +建议明确选择一种: + +```text +方案 A:开启 AUTOMOC,后续工具模块正常使用 QObject 信号槽 +方案 B:保持 OFF,后续模块不得引入 Q_OBJECT +``` + +当前阶段选择方案 B:继续保持 `CMAKE_AUTOMOC OFF`,新增意图分发模块使用普通 C++ 类和同步返回值,不引入 `Q_OBJECT`。 + +后续如果 Reminder / Weather / WebChat 等模块需要大量跨对象异步信号,再单独评估是否切换到方案 A。 + +--- + +### 2.6 README 与实际功能保持同步 + +当前 README 已经比较完整,但后续每实现一个功能,应同步更新: + +```text +当前状态 +配置说明 +隐私说明 +日志说明 +用户数据目录 +发布包包含内容 +``` + +尤其是以下功能需要额外说明: + +```text +提醒数据保存位置 +天气 API 请求说明 +IP 定位隐私说明 +本地文件操作权限说明 +联网模式来源和隐私说明 +``` + +--- + +### 2.7 继续保持角色导入安全策略 + +当前角色导入已经有较多安全边界,比如: + +```text +只导入本地文件夹 +验证失败不复制 +角色 id 安全字符限制 +内置角色不能被覆盖 +只允许删除用户导入角色 +状态路径安全检查 +``` + +后续本地文件操作模块也应该沿用这种风格: + +```text +路径必须安全 +不允许任意系统目录 +危险操作必须确认 +修改前备份 +删除必须二次确认 +``` + +--- + +## 3. 后续功能总路线 + +建议不要一次做完所有功能,按以下顺序推进: + +```text +阶段 0:结构收口 +阶段 1:定时提醒 +阶段 2:天气查询 +阶段 3:本地文件操作 +阶段 4:联网模式 +阶段 5:语音对话 / 更复杂 Agent 能力 +``` + +当前结构收口和定时提醒已经进入实现阶段。下一步最推荐继续做: + +```text +1. 天气查询 +2. 本地文件操作安全边界 +3. 联网模式 +``` + +--- + +# 4. 阶段 0:结构收口任务 + +## 4.1 目标 + +在正式加新功能之前,先让用户输入有统一分发入口,避免所有功能混进聊天逻辑。 + +## 4.2 当前新增文件 + +```text +src/assistant/UserIntent.h +src/assistant/IntentRouter.h +src/assistant/IntentRouter.cpp +src/assistant/CommandDispatcher.h +src/assistant/CommandDispatcher.cpp +``` + +## 4.3 IntentRouter 职责 + +```text +判断用户输入属于什么类型: +- 普通聊天 +- 定时提醒 +- 天气查询 +- 本地文件操作 +- 联网模式 +``` + +第一版可以使用规则判断,不依赖 AI。 + +## 4.4 CommandDispatcher 职责 + +```text +接收用户输入 +调用 IntentRouter +根据意图分发到不同 Manager +普通聊天才交给现有 ConversationManager / LLMProvider +``` + +## 4.5 PetWindow 改造要求 + +`PetWindow` 只负责把用户输入交给 `CommandDispatcher`,不直接判断业务逻辑。 + +示例流程: + +```text +PetWindow::submitChatMessage() +↓ +CommandDispatcher::dispatch(userText) +↓ +根据返回信号更新气泡、状态机、历史 +``` + +不要继续在 `PetWindow` 中直接写提醒、天气、搜索或文件逻辑。 + +--- + +# 5. 阶段 1:定时提醒功能 + +## 5.1 功能定位 + +实现本地一次性和基础重复提醒功能。 + +用户可以输入: + +```text +晚上8点提醒我提交作业 +明天9点提醒我开会 +10分钟后提醒我喝水 +半小时后提醒我休息 +2小时后提醒我看消息 +``` + +桌宠创建本地提醒,到点后气泡提示或托盘提示。 + +当前实现状态: + +```text +已新增 src/reminder/ 模块 +已支持一次性提醒解析、JSON 持久化、启动后加载、到点触发和状态标记 +已支持聊天创建 / 查询 / 取消提醒 +已支持设置页按状态查看提醒、取消 pending 提醒、编辑 pending 提醒、清理 20 天前已触发/已取消历史 +已支持 reminder_default / reminder_soft 内置音效 +已支持用户 wav 音效导入、删除、切换和试听 +已限制用户音效删除路径,只允许删除用户音效目录内的安全 sound id +提醒触发时使用当前设置页选择的全局音效,ReminderItem.soundId 仅保留为历史兼容字段 +已接入 Qt Multimedia / QSoundEffect 播放提醒音效 +已预留 NotificationDispatcher,当前 Windows 仍由托盘通知承接 +已支持每天 / 每周 / 每月重复提醒 +已支持提醒触发后的“知道了”和“5分钟后再提醒” +已支持提醒数据原子保存 +已支持多提醒可见队列,避免同时触发时互相覆盖 +已支持 60 秒兜底扫描,覆盖睡眠唤醒、系统时间变化和长间隔 timer 延迟场景 +通知后端不可用时会记录日志,不补气泡 +``` + +## 5.2 第一版范围 + +要做: + +```text +一次性提醒 +每天 / 每周 / 每月重复提醒 +本地保存 +程序重启后提醒不丢 +到点后触发气泡和托盘通知 +提醒触发后标记 triggered +支持查询当前提醒列表 +支持取消提醒 +``` + +暂不做: + +```text +工作日提醒 +自定义间隔重复提醒 +农历提醒 +复杂日历 +跨设备同步 +联网日程 +语音提醒 +``` + +## 5.3 建议目录 + +```text +src/reminder/ + ├── ReminderTypes.h + ├── ReminderCommandHandler.h + ├── ReminderCommandHandler.cpp + ├── ReminderParser.h + ├── ReminderParser.cpp + ├── ReminderManager.h + ├── ReminderManager.cpp + ├── ReminderStore.h + ├── ReminderStore.cpp + ├── ReminderSoundRepository.h + ├── ReminderSoundRepository.cpp + ├── ReminderSoundPlayer.h + └── ReminderSoundPlayer.cpp +``` + +## 5.4 数据结构建议 + +```cpp +struct ReminderItem +{ + QString id; + QString title; + QString originalText; + QDateTime remindAt; + ReminderStatus status = ReminderStatus::Pending; + QDateTime createdAt; + QDateTime finishedAt; // triggered/canceled 记录的完成时间;旧数据缺失时按 remindAt 兼容 + QString soundId; // 历史兼容字段,触发时不再读取 + ReminderRecurrence recurrence; +}; + +struct ReminderRecurrence +{ + ReminderRecurrenceType type = ReminderRecurrenceType::None; // None / Daily / Weekly / Monthly + int interval = 1; + int weekday = 0; // 1-7,仅 weekly 使用 + int monthDay = 0; // 1-31,仅 monthly 使用 + int hour = -1; + int minute = -1; + QDateTime lastTriggeredAt; +}; +``` + +## 5.5 存储文件 + +保存到: + +```text +QStandardPaths::AppConfigLocation/reminders.json +``` + +示例: + +```json +{ + "reminders": [ + { + "id": "reminder_001", + "title": "提交作业", + "originalText": "晚上8点提醒我提交作业", + "remindAt": "2026-06-01T20:00:00", + "status": "pending", + "createdAt": "2026-06-01T15:20:00", + "soundId": "", + "recurrence": { + "type": "none", + "interval": 1, + "weekday": 0, + "monthDay": 0, + "hour": -1, + "minute": -1 + } + } + ] +} +``` + +旧版 `reminders.json` 没有 `recurrence` 字段时按一次性提醒读取,继续兼容;没有 `finishedAt` 字段时,已触发/已取消历史按 `remindAt` 作为保留时间兜底。 + +提醒数据保存使用原子写入。写入失败时创建 / 取消会回滚内存变更,到点触发会保留 pending 并等待下次重试,不播放音效、不通知、不气泡。 + +历史记录默认只保留最近 20 天。自动清理和设置页“清理20天前历史”只删除超过 20 天的 triggered/canceled 记录,不删除 pending。 + +配置损坏时备份: + +```text +reminders.broken.yyyyMMdd-HHmmss.json +``` + +## 5.6 时间解析策略 + +第一版规则解析优先,不依赖 AI。 + +支持: + +```text +8点 +8点30 +20:30 +晚上8点 +下午3点 +明天9点 +明天上午10点 +后天9点 +今天下午3点 +6月3日9点 +6/3 09:00 +下周一上午10点 +10分钟后 +半小时后 +一个半小时后 +一小时后 +1小时后 +两小时后 +2小时后 +每天9点 +每天提醒我9点打卡 +每日晚上8点 +每周一上午10点 +每周一提醒我上午10点周会 +每星期五下午3点 +每月3号9点 +每月3号提醒我9点交报告 +``` + +如果规则解析失败,后续可以再接 AI 解析。当前重复提醒只支持每天 / 每周 / 每月。包含“工作日 / 每两天 / 每月最后一天 / 每季度 / 农历 / 自定义复杂规则”等语义时,返回明确暂不支持提示,不创建一次性提醒。 + +## 5.7 AI 辅助解析的设计边界 + +可以后续做 AI 辅助解析,但 AI 不允许直接写文件。 + +正确流程: + +```text +AI 解析用户意图 +↓ +返回结构化 JSON +↓ +程序校验 +↓ +ReminderManager 创建提醒 +↓ +ReminderStore 保存 +``` + +禁止: + +```text +AI 直接读写 reminders.json +AI 直接修改本地文件 +``` + +AI 解析时必须由程序提供当前本地时间: + +```text +currentLocalTime +timeZone +weekday +userText +``` + +因为 API 模型无法可靠知道用户本机时间。 + +## 5.8 到点触发 + +建议行为: + +```text +桌宠可见:播放当前全局音效,显示 ChatBubble + 切 happy,无 happy 时回退 talk,不发 Windows 通知 +桌宠隐藏:播放当前全局音效,触发 Windows 托盘通知,不在下次显示时补气泡 +AI 正在请求或流式回复:按隐藏场景处理,播放当前全局音效并发 Windows 通知,不显示气泡,不进入补气泡队列 +Windows 托盘通知后端不可用:记录日志,不补气泡,不进入可见队列 +用户拖动中:播放当前全局音效,不打断 drag,拖动结束后显示气泡,不发 Windows 通知 +多条提醒同时触发:可见状态下进入队列逐条展示,避免后一条覆盖前一条 +桌宠可见触发时显示轻量操作区:“知道了”关闭当前提示,“5分钟后再提醒”固定创建新的 5 分钟一次性提醒 +重复提醒触发后先推进下一次 remindAt 并保存;稍后提醒不会影响原重复规则 +``` + +提醒文案: + +```text +到时间啦:提交作业 +``` + +## 5.9 给 Codex 的阶段任务 + +```text +1. 新增 reminder 模块 +2. 实现一次性 ReminderItem +3. 实现 ReminderStore JSON 读写 +4. 实现 ReminderParser 规则解析 +5. 实现 ReminderManager 定时检查 +6. 到点后发信号给 UI 层 +7. UI 层显示气泡和托盘通知 +8. 不要把提醒逻辑写进 PetWindow +9. 不要让 AI 直接写本地文件 +``` + +--- + +# 6. 阶段 2:天气查询功能 + +当前天气查询 v1 已进入实现阶段: + +- 已新增独立 `src/weather/` 模块 +- 已支持 Open-Meteo 市级城市优先的基础地理编码和基础天气查询 +- 已支持设置页默认城市 +- 已支持默认城市为空时通过公网 IP 定位兜底 +- 已支持当前/今天、明天、后天和未来 1-3 天模板回复 +- 已支持读取前 5 个地理编码候选;多候选时仍查首项,并在回复中提示同名城市风险和其他候选 +- 已支持设置页测试默认城市;测试只展示匹配结果,不自动保存配置 +- 已通过 `CommandDispatcher` 将 Weather 意图接入聊天入口 +- 已保持 `PetWindow` 只负责 UI 展示,不承载天气解析或网络逻辑 + +v1 明确暂不支持: + +- AI 润色天气回复 +- 空气质量 +- 天气预警 +- 天气提醒联动 +- 多天气源切换 +- 小时级精细降雨判断 +- 穿衣指数 + +默认城市为空且启用公网 IP 定位时,会请求 ipapi.co 判断城市;回复必须说明“根据公网 IP 判断城市”。设置页默认城市和公网 IP 定位都属于非用户明确城市来源,回复必须说明来源。 + +当前 v1 推荐填写市级城市名;区县、乡镇、街道不保证精确识别,可能无法匹配或被匹配到上级/同名城市。同名城市当前使用天气源返回的第一个结果,并在回复或测试结果中提示其他候选。后续优先补可交互候选选择、区县级定位和国内天气源增强。 + +## 6.1 功能定位 + +天气是独立工具能力,不放进联网模式。 + +正确流程: + +```text +用户问天气 +↓ +程序识别 Weather 意图 +↓ +WeatherManager 调用天气 API +↓ +拿到结构化天气数据 +↓ +AI 根据结构化数据自然语言回复 +``` + +不要让 AI 自己搜索天气,也不要让 AI 自己拼 URL。 + +## 6.2 为什么不用联网模式做天气 + +用联网模式查询天气有几个问题: + +```text +结果格式不稳定 +摘要可能过期 +地区解析不稳定 +无法稳定提取温度、降雨、风力、湿度等字段 +AI 总结容易出错 +``` + +天气应该走天气 API。 + +## 6.3 第一版范围 + +要做: + +```text +识别天气请求 +支持默认城市 +支持用户指定城市 +支持当前天气 +支持简单今天/明天查询 +AI 不可用时模板兜底回复 +查询中切 think +回复时切 talk +失败时切 error +``` + +暂不做: + +```text +天气预警 +空气质量详情 +分钟级降水 +生活指数 +地图定位 +GPS 定位 +自动后台推送 +天气提醒 +多天气源自动切换 +``` + +## 6.4 建议目录 + +```text +src/weather/ + ├── WeatherTypes.h + ├── WeatherConfig.h + ├── WeatherStore.h + ├── WeatherStore.cpp + ├── WeatherParser.h + ├── WeatherParser.cpp + ├── WeatherSummaryFormatter.h + ├── WeatherSummaryFormatter.cpp + ├── WeatherManager.h + └── WeatherManager.cpp +``` + +## 6.5 天气源建议 + +第一版推荐: + +```text +Open-MeteoProvider +``` + +优点: + +```text +不需要 API Key +全球可用 +适合开源项目默认方案 +``` + +后续再加: + +```text +AmapWeatherProvider +OpenWeatherProvider +``` + +如果主要面向国内用户,高德天气也可以作为第二个 Provider,但需要用户配置 Web 服务 Key。 + +## 6.6 WeatherConfig + +```cpp +struct WeatherConfig +{ + bool enabled = true; + + QString provider = "open-meteo"; + + QString defaultLocationName; + double defaultLatitude = 0.0; + double defaultLongitude = 0.0; + bool hasDefaultCoordinate = false; + + bool allowIpLocation = false; + bool askBeforeIpLocation = true; + + QString apiKey; + int timeoutMs = 10000; + + bool useAIResponse = true; + bool showRawDataWhenAIUnavailable = true; +}; +``` + +保存到: + +```text +QStandardPaths::AppConfigLocation/weather_config.json +``` + +## 6.7 地点策略 + +优先级: + +```text +用户明确输入城市 +↓ +用户设置的默认城市 +↓ +用户主动选择 IP 定位 +↓ +追问用户城市 +``` + +不要默认静默使用 IP 定位。 + +## 6.8 IP 定位原则 + +IP 定位可以做,但只能作为可选辅助。 + +原因: + +```text +VPN / 代理会导致定位错误 +运营商出口可能定位不准 +校园网 / 公司网可能不准 +用户可能不希望程序自动请求定位服务 +``` + +建议交互: + +```text +你还没有设置默认城市。 +可以手动输入城市,也可以使用 IP 自动定位。 +是否使用 IP 自动定位? +``` + +识别到城市后仍要确认: + +```text +我识别到你可能在西安,要设为默认城市吗? +``` + +## 6.9 天气意图关键词 + +```text +天气 +气温 +温度 +冷不冷 +热不热 +下雨 +降雨 +带伞 +刮风 +风力 +湿度 +空气质量 +雾霾 +适合出门 +穿什么 +外面怎么样 +``` + +## 6.10 AI 回复上下文 + +当前 v1 采用模板优先,不依赖 AI 润色;本节作为 v1.1 预留方向。 + +程序应把结构化天气数据交给 AI: + +```text +用户问题: +今天西安天气怎么样? + +天气数据: +城市:西安 +当前天气:多云 +温度:26℃ +体感温度:27℃ +湿度:60% +风向:东北风 +风速:3级 +降雨概率:20% +更新时间:2026-06-01 15:00 + +请根据以上数据,用简洁自然的语气回答。不要编造没有提供的数据。 +``` + +## 6.11 AI 不可用时兜底 + +如果天气 API 成功但 AI 失败,则模板回复: + +```text +西安当前天气:多云,26℃,湿度 60%,风力 3 级。更新时间:15:00。 +``` + +--- + +# 7. 阶段 3:本地文件操作功能 + +## 7.1 功能定位 + +本地文件操作是高风险功能,必须后置,不能早于提醒和天气。 + +当前本地文件操作 v1 已进入实现阶段: + +- 已新增独立 `src/fileops/` 模块 +- 已通过 `CommandDispatcher` 将 FileOperation 意图接入聊天入口 +- 已支持读取用户主动选择的常见文本文件 +- 已支持列出用户主动选择的文件夹 +- 已支持复制文件、创建备份、重命名文件 +- 写操作会展示操作计划并二次确认 +- 聊天文本不会直接变成本地路径,所有路径必须由用户通过文件选择框选择 +- 已拒绝删除、覆盖、移动、执行脚本、运行命令和系统目录访问 +- 已拒绝符号链接路径,降低路径逃逸风险 + +v1 明确暂不支持: + +- zip 打包 +- 删除文件 +- 覆盖文件 +- 移动文件 +- 执行脚本或命令 +- 修改源码 + +目标是让桌宠能辅助用户处理本地文件,例如: + +```text +帮我整理这个文件夹里的图片 +帮我把这些日志文件打包 +帮我读取这个 txt 总结一下 +帮我把这个 md 转成备份副本 +``` + +## 7.2 基本原则 + +AI 不能直接操作本地文件。 + +正确流程: + +```text +AI 理解用户意图 +↓ +生成操作计划 +↓ +程序校验路径和权限 +↓ +展示计划给用户确认 +↓ +程序执行 +↓ +执行结果反馈给 AI / 用户 +``` + +禁止: + +```text +AI 直接读写任意文件 +AI 直接删除文件 +AI 直接覆盖文件 +AI 自己决定访问系统目录 +``` + +## 7.3 安全边界 + +必须实现: + +```text +工作目录白名单 +路径标准化 +禁止符号链接逃逸 +禁止访问系统目录 +禁止访问用户隐私目录,除非用户显式选择 +修改前备份 +删除前二次确认 +批量操作前显示操作计划 +``` + +## 7.4 建议目录 + +```text +src/fileops/ + ├── FileOperationTypes.h + ├── FileOperationManager.h + ├── FileOperationManager.cpp + ├── FileSandbox.h + ├── FileSandbox.cpp + ├── FileBackupManager.h + └── FileBackupManager.cpp +``` + +## 7.5 第一版建议只做低风险操作 + +第一版可做: + +```text +读取用户主动选择的文本文件 +列出用户主动选择的文件夹 +复制文件 +创建备份 +打包 zip +重命名文件,需确认 +``` + +当前 v1 中 zip 打包延期,因为 Qt 公共 API 没有稳定内置写 zip 能力;不为此引入重依赖。后续如果要补 zip,优先选择明确许可和可维护的压缩库。 + +暂不做: + +```text +删除文件 +覆盖文件 +移动大量文件 +修改源码 +执行脚本 +运行命令 +访问系统目录 +``` + +## 7.6 修改/删除必须二次确认 + +即使用户说“删掉它”,也必须确认: + +```text +即将删除: +D:/xxx/a.txt +D:/xxx/b.txt + +此操作会移动到回收站/备份后删除。 +是否继续? +``` + +## 7.7 应用启动独立模块 + +当前已新增独立 `src/launcher/` 模块,不放进 `src/fileops/`: + +```text +src/launcher/ + ├── AppLaunchTypes.h + ├── AppLaunchStore.h + ├── AppLaunchStore.cpp + ├── AppDiscovery.h + ├── AppDiscovery.cpp + ├── AppLaunchManager.h + └── AppLaunchManager.cpp +``` + +应用启动支持: + +```text +打开 Codex +启动酷狗音乐 +帮我打开 VSCode +``` + +解析和发现顺序: + +```text +用户在设置页登记的应用别名 +↓ +Windows 开始菜单快捷方式 +↓ +Windows App Paths 注册表 +↓ +未知应用时由用户手动选择 .exe +``` + +启动安全边界: + +```text +启动前始终二次确认 +只允许 .exe 或开始菜单 .lnk +不执行 .bat / .cmd / .ps1 / .vbs / .js / .msi +不执行聊天文本里的命令 +不拼接聊天文本参数 +不以管理员权限启动 +``` + +配置保存到: + +```text +QStandardPaths::AppConfigLocation/launcher_config.json +``` + +损坏时备份为: + +```text +launcher_config.broken.yyyyMMdd-HHmmss.json +``` + +--- + +# 8. 阶段 4:联网模式 + +联网能力已从旧“搜索引擎聚合器”重做为 Web 端 AI 对话式联网模式。 + +当前已落地: + +- 已删除旧 `src/search/` 模块,不再维护 Google/百度/360/搜狗页面解析、SearXNG 旧配置、多源聚合和旧 `search_config.json` 兼容。 +- 已新增独立 `src/web/` 模块:`WebConfig`、`WebStore`、`WebCapabilityDetector`、`WebChatManager` 和 Web citation 类型。 +- 输入框新增“联网”开关;开关开启后,普通聊天进入 WebChat 流程。 +- 支持 OpenAI 官方 Provider 的 Responses API Web Search。 +- 支持 Google Gemini Provider 的 Google Search grounding。 +- DeepSeek 官方 API 当前不提供托管联网搜索工具,显示“不支持原生联网”。 +- Custom / 第三方 OpenAI-Compatible 默认无法确认联网能力,不发送未知联网参数,不做旧搜索页抓取。 +- 设置页改为“联网模式”:显示当前能力状态、开关记忆、默认开关、Provider 模式、超时、来源展示和测试联网模式。 +- 旧 `search_config.json` 已废弃;新版使用 `web_config.json`,损坏时备份为 `web_config.broken.yyyyMMdd-HHmmss.json`。 + +边界: + +- 不再默认每次联网,模型可判断稳定常识无需联网。 +- 不做搜索结果页 HTML 解析,避免登录、帮助、反馈、验证码页面污染答案。 +- 不实现 Tavily / Brave / Bing Search API、自建搜索后端、网页全文抓取或长期缓存。 +- 天气仍是独立工具能力,不放进联网模式。 + +后续可选: + +- 更多 AI Provider 原生联网适配。 +- 结构化搜索 API 或自建联网后端,用于不支持原生联网的模型。 +- 更强的引用 UI、证据片段摘录和来源可点击展示。 + +# 9. 建议最终开发顺序 + +## 9.1 第一步:收口架构 + +```text +1. 角色切换保存后立即生效已确认,后续作为回归项保留 +2. 新增 IntentRouter +3. 新增 CommandDispatcher +4. 调整 PetWindow 输入流程 +5. 当前阶段保持 CMAKE_AUTOMOC OFF,新增模块不使用 Q_OBJECT +``` + +## 9.2 第二步:定时提醒 + +```text +当前已落地一次性提醒、每天/每周/每月重复提醒、提醒列表、取消提醒、编辑 pending 提醒、20 天历史保留、到点气泡/托盘通知、稍后提醒和提醒音效管理。 +后续可继续补工作日/自定义间隔、跨平台通知实现和更复杂的提醒管理能力。 +``` + +## 9.3 第三步:天气查询 + +```text +当前已落地天气 v1:WeatherTypes、WeatherConfig / WeatherStore、WeatherParser、WeatherManager、模板回复、设置页默认城市、公网 IP 定位兜底和 Weather 意图接入。 +当前已落地天气 v1.2 / v1.3:多候选城市提示和设置页默认城市测试。后续可继续补可交互候选选择、区县级定位、国内天气源增强、AI 润色、空气质量、天气预警、多天气源切换、小时级降雨判断和天气提醒联动。 +``` + +## 9.4 第四步:本地文件操作 + +```text +当前已落地 FileSandbox、FileOperationTypes、FileOperationManager、FileBackupManager 和用户确认 UI。 +v1 支持读取文本、列出文件夹、复制、备份、重命名。 +v1 不支持 zip、删除、覆盖、移动、脚本/命令执行和系统目录访问。 +``` + +## 9.5 第五步:联网模式 + +```text +当前已落地 WebConfig、WebStore、WebCapabilityDetector、WebChatManager 和输入框联网开关。新版支持 OpenAI/Gemini 原生联网;DeepSeek/Custom 明确提示不支持或无法确认;旧搜索聚合与 SearXNG 旧配置已废弃。 +``` + +## 9.6 第六步:应用启动 + +```text +当前已落地 AppLaunchStore、AppDiscovery、AppLaunchManager 和设置页应用登记。 +v1 支持已登记应用、开始菜单快捷方式、App Paths 注册表和用户确认手选 .exe。 +v1 不支持脚本、聊天参数、命令执行、管理员权限和跨平台应用发现。 +``` + +--- + +# 10. 给 Codex 的总提示 + +下面这段可以直接作为后续任务总说明: + +```text +当前不要急着直接实现提醒、天气、文件操作或联网模式。 + +请先审查当前 QtDesktopPet 项目结构,并完成以下准备工作: + +1. 角色切换在设置保存后已确认立即生效,后续只需保留回归检查。 +2. 新增 IntentRouter / CommandDispatcher,使用户输入先经过统一意图分发。 +3. 意图类型包括 Chat、Reminder、Weather、FileOperation、LaunchApp;联网模式由输入框开关决定,不再是独立 Search 意图。 +4. 意图优先级为 Reminder > FileOperation > Weather > LaunchApp > Chat/WebChat。 +5. 普通聊天继续走现有 AI 对话流程。 +6. 新功能不得继续直接塞进 PetWindow。 +7. 后续 Reminder、Weather、FileOperation、LaunchApp、WebChat 均应作为独立模块接入。 +8. 当前阶段保持 CMAKE_AUTOMOC OFF,后续模块不要使用 Q_OBJECT;确需 Qt 信号槽时再单独评估。 +9. 保持现有隐私策略:API Key、Authorization、完整用户消息、完整错误响应不得写入日志。 +10. 保持现有文件安全策略:路径校验、危险操作确认、修改前备份。 +``` + +--- + +# 11. 当前结论 + +项目已经可以进入“工具能力扩展阶段”,但不建议直接开写业务功能。 + +建议先做: + +```text +结构收口 → IntentRouter / CommandDispatcher → 定时提醒 → 天气 → 本地文件操作 → 联网模式 +``` + +其中: + +```text +定时提醒:已作为第一个工具能力落地 +天气:建议第二个落地 +本地文件操作:风险较高,第三个落地 +联网模式:通用 AI 对话增强,最后落地 +``` diff --git a/docs/Qt_DesktopPet_开发文档.md b/docs/archive/Qt_DesktopPet_开发文档.md similarity index 100% rename from docs/Qt_DesktopPet_开发文档.md rename to docs/archive/Qt_DesktopPet_开发文档.md diff --git a/docs/implementation_plan.md b/docs/archive/implementation_plan.md similarity index 100% rename from docs/implementation_plan.md rename to docs/archive/implementation_plan.md diff --git a/docs/performance_stability_check.md b/docs/performance_stability_check.md index ca9f405..87e8b4b 100644 --- a/docs/performance_stability_check.md +++ b/docs/performance_stability_check.md @@ -58,6 +58,7 @@ reports/perf/ | 联网模式 | 使用支持联网的 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 | 不做创建、修改、移动、删除;使用临时测试仓库,不操作真实重要项目 | | 配置损坏兜底 | 备份后分别破坏 app 配置、AI 配置或本地聊天记录再启动 | 启动后采样 3 分钟 | 程序恢复默认配置或忽略损坏历史,并生成带时间戳的 broken 备份,不覆盖旧备份 | TODO | TODO | 使用备份副本测试 | | 角色包损坏兜底 | 使用临时复制的损坏角色包测试 | 启动后采样 3 分钟 | 程序不崩溃,回退 preview 或默认显示 | TODO | TODO | 不直接破坏仓库内默认角色包 | diff --git a/src/assistant/CommandDispatcher.cpp b/src/assistant/CommandDispatcher.cpp index 07086b6..a67022d 100644 --- a/src/assistant/CommandDispatcher.cpp +++ b/src/assistant/CommandDispatcher.cpp @@ -10,6 +10,8 @@ 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: @@ -37,6 +39,11 @@ CommandDispatchResult CommandDispatcher::dispatch(const QString &text) const 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}; @@ -58,6 +65,8 @@ QString CommandDispatcher::unsupportedToolMessage(UserIntentType type) const return QStringLiteral("提醒请求暂时无法处理,请稍后再试。"); case UserIntentType::Weather: return QStringLiteral("天气查询请求暂时无法处理,请稍后再试。"); + case UserIntentType::Workspace: + return QStringLiteral("本地工作区请求暂时无法处理,请稍后再试。"); case UserIntentType::FileOperation: return QStringLiteral("本地文件操作请求暂时无法处理,请稍后再试。"); case UserIntentType::LaunchApp: diff --git a/src/assistant/CommandDispatcher.h b/src/assistant/CommandDispatcher.h index 3f990dc..184b3ed 100644 --- a/src/assistant/CommandDispatcher.h +++ b/src/assistant/CommandDispatcher.h @@ -9,6 +9,7 @@ enum class CommandDispatchAction Chat, Reminder, Weather, + Workspace, FileOperation, LaunchApp, UnsupportedTool, diff --git a/src/assistant/IntentRouter.cpp b/src/assistant/IntentRouter.cpp index d2f43a2..84200ea 100644 --- a/src/assistant/IntentRouter.cpp +++ b/src/assistant/IntentRouter.cpp @@ -18,6 +18,116 @@ bool containsAny(const QString &text, const QStringList &keywords) 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 = { @@ -40,6 +150,13 @@ bool isFileOperationIntent(const QString &text) QStringLiteral("截图"), QStringLiteral("打包"), QStringLiteral("压缩"), + QStringLiteral("创建"), + QStringLiteral("新建"), + QStringLiteral("写入"), + QStringLiteral("修改"), + QStringLiteral("修复"), + QStringLiteral("优化"), + QStringLiteral("覆盖"), QStringLiteral("复制"), QStringLiteral("备份"), QStringLiteral("重命名"), @@ -76,6 +193,180 @@ 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 = { @@ -117,16 +408,21 @@ UserIntent IntentRouter::route(const QString &text) const return {UserIntentType::Reminder, trimmedText}; } - if (isFileOperationIntent(trimmedText)) - { - return {UserIntentType::FileOperation, 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}; diff --git a/src/assistant/UserIntent.h b/src/assistant/UserIntent.h index 0f8e956..ee454ab 100644 --- a/src/assistant/UserIntent.h +++ b/src/assistant/UserIntent.h @@ -7,6 +7,7 @@ enum class UserIntentType Chat, Reminder, Weather, + Workspace, FileOperation, LaunchApp, }; diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index 4ec1678..5090763 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -24,4 +24,7 @@ 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 8e886cd..a35c8bc 100644 --- a/src/config/ConfigManager.cpp +++ b/src/config/ConfigManager.cpp @@ -74,6 +74,15 @@ 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(); @@ -301,6 +310,24 @@ 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; } @@ -391,6 +418,7 @@ 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)) diff --git a/src/system/GlobalHotkeyManager.cpp b/src/system/GlobalHotkeyManager.cpp new file mode 100644 index 0000000..3b90849 --- /dev/null +++ b/src/system/GlobalHotkeyManager.cpp @@ -0,0 +1,209 @@ +#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 new file mode 100644 index 0000000..f6992e7 --- /dev/null +++ b/src/system/GlobalHotkeyManager.h @@ -0,0 +1,30 @@ +#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 6b1d1bc..4758ff0 100644 --- a/src/tray/TrayController.cpp +++ b/src/tray/TrayController.cpp @@ -85,6 +85,11 @@ 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(); @@ -137,6 +142,16 @@ 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 4b404e4..4d591d2 100644 --- a/src/tray/TrayController.h +++ b/src/tray/TrayController.h @@ -19,6 +19,7 @@ private: void showPetWindow(); void hidePetWindow(); void togglePetWindowVisibility(); + void showWorkspacePanel(); void openSettings(); void quitApplication(); diff --git a/src/ui/ChatInputDialog.cpp b/src/ui/ChatInputDialog.cpp index e78df94..66d0b37 100644 --- a/src/ui/ChatInputDialog.cpp +++ b/src/ui/ChatInputDialog.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -106,6 +107,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent) connect(m_textEdit, &QTextEdit::textChanged, this, [this]() { updateCounter(); }); + connect(m_webToggleCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + if (m_webToggleChangedCallback) + { + m_webToggleChangedCallback(checked); + } + }); auto *panel = new QFrame(this); panel->setObjectName(QStringLiteral("InputPanel")); @@ -143,6 +150,7 @@ void ChatInputDialog::setWebEnabled(bool enabled) { if (m_webToggleCheckBox) { + const QSignalBlocker blocker(m_webToggleCheckBox); m_webToggleCheckBox->setChecked(enabled); } } @@ -154,6 +162,7 @@ void ChatInputDialog::setWebToggleAvailable(bool available, const QString &toolT return; } + const QSignalBlocker blocker(m_webToggleCheckBox); m_webToggleCheckBox->setEnabled(available); if (!available) { @@ -169,6 +178,11 @@ void ChatInputDialog::setSubmitCallback(SubmitCallback callback) m_submitCallback = std::move(callback); } +void ChatInputDialog::setWebToggleChangedCallback(WebToggleChangedCallback callback) +{ + m_webToggleChangedCallback = std::move(callback); +} + void ChatInputDialog::showAt(const QPoint &anchorPosition) { move(anchorPosition.x() - width() / 2, anchorPosition.y() - height() / 2); diff --git a/src/ui/ChatInputDialog.h b/src/ui/ChatInputDialog.h index b48bd8c..405e0fd 100644 --- a/src/ui/ChatInputDialog.h +++ b/src/ui/ChatInputDialog.h @@ -17,6 +17,7 @@ class ChatInputDialog : public QDialog { public: using SubmitCallback = std::function; + using WebToggleChangedCallback = std::function; explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr); @@ -25,6 +26,7 @@ public: void setWebEnabled(bool enabled); void setWebToggleAvailable(bool available, const QString &toolTip = QString()); void setSubmitCallback(SubmitCallback callback); + void setWebToggleChangedCallback(WebToggleChangedCallback callback); void showAt(const QPoint &anchorPosition); protected: @@ -48,6 +50,7 @@ private: QCheckBox *m_webToggleCheckBox = nullptr; QPushButton *m_sendButton = nullptr; SubmitCallback m_submitCallback; + WebToggleChangedCallback m_webToggleChangedCallback; QPoint m_dragStartPosition; QPoint m_dragWindowPosition; int m_maxLength = 0; diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 19751f9..48b921c 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -15,6 +15,7 @@ #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" @@ -22,11 +23,14 @@ #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 @@ -57,6 +61,7 @@ #include #include #include +#include #include #include @@ -117,6 +122,11 @@ 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; } @@ -192,6 +202,12 @@ 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) @@ -202,6 +218,9 @@ PetWindow::PetWindow(QWidget *parent) , 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()) @@ -255,6 +274,51 @@ PetWindow::PetWindow(QWidget *parent) m_chatInputDialog->setSubmitCallback([window](const QString &message, bool webEnabled) { return !window.isNull() && window->submitChatMessage(message, webEnabled); }); + m_chatInputDialog->setWebToggleChangedCallback([window](bool webEnabled) { + if (!window.isNull()) + { + window->saveWebTogglePreference(webEnabled); + } + }); + m_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()) @@ -287,6 +351,7 @@ void PetWindow::applyAppConfig(const AppConfig &config) m_appConfig = normalizedConfig; setAlwaysOnTop(m_appConfig.alwaysOnTop); + configureAgentHotkey(); configureConversation(loadPersistedHistory); if (m_appConfig.hasWindowPosition && isPointVisibleOnScreen(m_appConfig.windowPosition)) @@ -380,6 +445,33 @@ 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; @@ -576,6 +668,7 @@ 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("清空对话")); @@ -604,6 +697,10 @@ void PetWindow::contextMenuEvent(QContextMenuEvent *event) { showConversationHistory(); } + else if (selectedAction == workspacePanelAction) + { + showWorkspacePanelFromUserAction(); + } else if (selectedAction == cancelAIAction) { cancelActiveAIRequest(); @@ -669,6 +766,11 @@ 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); @@ -707,6 +809,187 @@ 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) { if (!m_weatherManager) @@ -778,6 +1061,15 @@ bool PetWindow::handleFileOperationChatMessage(const QString &message) 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("命令"))) { @@ -1065,6 +1357,203 @@ bool PetWindow::handleLaunchAppChatMessage(const QString &message) 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) { @@ -1082,6 +1571,13 @@ 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); @@ -1185,6 +1681,11 @@ 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(); @@ -1508,6 +2009,12 @@ bool PetWindow::submitAiChatMessage(const QString &message) return false; } + if (hasActiveWorkspaceAIRequest()) + { + showBubbleMessage(QStringLiteral("工作区分析正在进行,请稍后。")); + return false; + } + if (!m_conversationManager || m_conversationManager->isBusy()) { showBubbleMessage(QStringLiteral("AI 回复正在进行。")); @@ -1617,6 +2124,10 @@ void PetWindow::clearConversation() { m_webChatManager->cancel(); } + if (m_workspaceAiProvider && m_workspaceAiProvider->isBusy()) + { + m_workspaceAiProvider->cancel(); + } refreshChatHistoryPanel(); showBubbleMessage(hadActiveRequest ? QStringLiteral("已取消当前请求,并清空对话。") @@ -1643,12 +2154,22 @@ 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(); + return (m_conversationManager && m_conversationManager->isBusy()) + || hasActiveWebRequest() + || hasActiveWorkspaceAIRequest(); } bool PetWindow::isManualStateSwitchLocked() const diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index e2a8e8b..b475390 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -31,6 +31,8 @@ class ChatInputDialog; class ConversationManager; class ConversationStore; class FileOperationManager; +class GlobalHotkeyManager; +class LLMProvider; class AppLaunchManager; class NotificationDispatcher; class PetView; @@ -38,6 +40,9 @@ class ReminderManager; class ReminderSoundPlayer; class WebChatManager; class WeatherManager; +class WorkspaceController; +class WorkspaceAgentWindow; +struct WorkspaceToolResult; class PetWindow : public QWidget { @@ -54,6 +59,8 @@ public: void pauseAnimation(); void resumeAnimation(); void showBubbleMessage(const QString &message); + void showWorkspaceAgentWindowFromUserAction(); + void showWorkspacePanelFromUserAction(); protected: void contextMenuEvent(QContextMenuEvent *event) override; @@ -76,10 +83,23 @@ 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); @@ -143,6 +163,10 @@ private: 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; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 041c3fe..cad52e0 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -27,6 +27,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -244,6 +247,30 @@ QString launchAppDisplayText(const RegisteredApp &app) 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; @@ -575,6 +602,11 @@ SettingsDialog::SettingsDialog( , 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)) @@ -975,6 +1007,48 @@ SettingsDialog::SettingsDialog( 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); m_reminderSoundStatusLabel->setObjectName(QStringLiteral("HintLabel")); @@ -1091,6 +1165,7 @@ SettingsDialog::SettingsDialog( navigationList->addItem(QStringLiteral("天气")); navigationList->addItem(QStringLiteral("联网")); navigationList->addItem(QStringLiteral("应用启动")); + navigationList->addItem(QStringLiteral("Agent")); navigationList->addItem(QStringLiteral("提醒")); navigationList->addItem(QStringLiteral("应用")); navigationList->addItem(QStringLiteral("角色")); @@ -1102,6 +1177,7 @@ SettingsDialog::SettingsDialog( pageStack->addWidget(weatherPage); pageStack->addWidget(webPage); pageStack->addWidget(launchPage); + pageStack->addWidget(agentPage); pageStack->addWidget(reminderPage); pageStack->addWidget(appPage); pageStack->addWidget(characterPage); @@ -1241,6 +1317,11 @@ SettingsDialog::SettingsDialog( 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(); }); @@ -1350,6 +1431,13 @@ 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; } @@ -1748,6 +1836,15 @@ 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 36ced80..13332c1 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -20,6 +20,7 @@ class QDoubleSpinBox; class QLabel; class QLineEdit; class QListWidget; +class QKeySequenceEdit; class QPushButton; class QSpinBox; class LLMProvider; @@ -150,6 +151,11 @@ private: 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; diff --git a/src/ui/WorkspaceAgentWindow.cpp b/src/ui/WorkspaceAgentWindow.cpp new file mode 100644 index 0000000..799bc46 --- /dev/null +++ b/src/ui/WorkspaceAgentWindow.cpp @@ -0,0 +1,676 @@ +#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 new file mode 100644 index 0000000..7d7485e --- /dev/null +++ b/src/ui/WorkspaceAgentWindow.h @@ -0,0 +1,89 @@ +#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 new file mode 100644 index 0000000..5cff641 --- /dev/null +++ b/src/ui/WorkspacePanel.cpp @@ -0,0 +1,506 @@ +#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 new file mode 100644 index 0000000..1570e3e --- /dev/null +++ b/src/ui/WorkspacePanel.h @@ -0,0 +1,67 @@ +#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/workspace/WorkspaceAccessPolicy.cpp b/src/workspace/WorkspaceAccessPolicy.cpp new file mode 100644 index 0000000..fdc06bb --- /dev/null +++ b/src/workspace/WorkspaceAccessPolicy.cpp @@ -0,0 +1,371 @@ +#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 new file mode 100644 index 0000000..430b676 --- /dev/null +++ b/src/workspace/WorkspaceAccessPolicy.h @@ -0,0 +1,36 @@ +#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 new file mode 100644 index 0000000..16febcb --- /dev/null +++ b/src/workspace/WorkspaceAgent.cpp @@ -0,0 +1,36 @@ +#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 new file mode 100644 index 0000000..e0c59f9 --- /dev/null +++ b/src/workspace/WorkspaceAgent.h @@ -0,0 +1,11 @@ +#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 new file mode 100644 index 0000000..33fbe5d --- /dev/null +++ b/src/workspace/WorkspaceController.cpp @@ -0,0 +1,1061 @@ +#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 new file mode 100644 index 0000000..1573157 --- /dev/null +++ b/src/workspace/WorkspaceController.h @@ -0,0 +1,52 @@ +#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 new file mode 100644 index 0000000..a9de7e8 --- /dev/null +++ b/src/workspace/WorkspaceIndex.cpp @@ -0,0 +1,137 @@ +#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 new file mode 100644 index 0000000..7e4821c --- /dev/null +++ b/src/workspace/WorkspaceIndex.h @@ -0,0 +1,23 @@ +#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 new file mode 100644 index 0000000..9947599 --- /dev/null +++ b/src/workspace/WorkspaceScanner.cpp @@ -0,0 +1,125 @@ +#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 new file mode 100644 index 0000000..f6a5ae1 --- /dev/null +++ b/src/workspace/WorkspaceScanner.h @@ -0,0 +1,22 @@ +#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 new file mode 100644 index 0000000..a7cc597 --- /dev/null +++ b/src/workspace/WorkspaceSession.cpp @@ -0,0 +1,90 @@ +#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 new file mode 100644 index 0000000..77f0a6f --- /dev/null +++ b/src/workspace/WorkspaceSession.h @@ -0,0 +1,44 @@ +#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 new file mode 100644 index 0000000..e745a94 --- /dev/null +++ b/src/workspace/WorkspaceTool.h @@ -0,0 +1,15 @@ +#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 new file mode 100644 index 0000000..3076ae8 --- /dev/null +++ b/src/workspace/WorkspaceTypes.h @@ -0,0 +1,97 @@ +#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 new file mode 100644 index 0000000..9fb4d1f --- /dev/null +++ b/src/workspace/tools/ListFilesTool.cpp @@ -0,0 +1,52 @@ +#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 new file mode 100644 index 0000000..6a0f1ee --- /dev/null +++ b/src/workspace/tools/ListFilesTool.h @@ -0,0 +1,15 @@ +#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 new file mode 100644 index 0000000..c15d71c --- /dev/null +++ b/src/workspace/tools/OpenDirectoryTool.cpp @@ -0,0 +1,56 @@ +#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 new file mode 100644 index 0000000..a3b2593 --- /dev/null +++ b/src/workspace/tools/OpenDirectoryTool.h @@ -0,0 +1,18 @@ +#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 new file mode 100644 index 0000000..fdb3a6e --- /dev/null +++ b/src/workspace/tools/OpenFileTool.cpp @@ -0,0 +1,85 @@ +#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 new file mode 100644 index 0000000..97baee0 --- /dev/null +++ b/src/workspace/tools/OpenFileTool.h @@ -0,0 +1,18 @@ +#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 new file mode 100644 index 0000000..5174ff9 --- /dev/null +++ b/src/workspace/tools/ReadFileTool.cpp @@ -0,0 +1,69 @@ +#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 new file mode 100644 index 0000000..2d5b4bc --- /dev/null +++ b/src/workspace/tools/ReadFileTool.h @@ -0,0 +1,18 @@ +#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 new file mode 100644 index 0000000..1a41af7 --- /dev/null +++ b/src/workspace/tools/RevealInFileManagerTool.cpp @@ -0,0 +1,75 @@ +#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 new file mode 100644 index 0000000..8fe68bc --- /dev/null +++ b/src/workspace/tools/RevealInFileManagerTool.h @@ -0,0 +1,18 @@ +#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 new file mode 100644 index 0000000..5878a5d --- /dev/null +++ b/src/workspace/tools/SearchFilesTool.cpp @@ -0,0 +1,39 @@ +#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 new file mode 100644 index 0000000..384ed21 --- /dev/null +++ b/src/workspace/tools/SearchFilesTool.h @@ -0,0 +1,15 @@ +#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 new file mode 100644 index 0000000..12b4939 --- /dev/null +++ b/src/workspace/tools/SearchTextTool.cpp @@ -0,0 +1,102 @@ +#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 new file mode 100644 index 0000000..2035345 --- /dev/null +++ b/src/workspace/tools/SearchTextTool.h @@ -0,0 +1,17 @@ +#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; +};