归档当前功能基线并完善工作区 Agent
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:默认无法确认联网能力,不会尝试旧搜索页抓取。
|
||||
- 输入框“联网”开关在启用“记住输入框联网开关状态”时会随用户切换立即保存,不依赖发送消息。
|
||||
|
||||
配置保存到:
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+78
-1188
File diff suppressed because it is too large
Load Diff
@@ -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 或构建命令 |
|
||||
|
||||
@@ -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 或构建命令。
|
||||
@@ -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<WorkspaceFileInfo> files;
|
||||
};
|
||||
```
|
||||
|
||||
## 6. 安全策略
|
||||
|
||||
`WorkspaceAccessPolicy` 是最重要的模块,所有工具必须经过它校验。
|
||||
|
||||
职责:
|
||||
|
||||
- 路径标准化。
|
||||
- 判断路径是否在工作区内。
|
||||
- 阻止 `../` 跳出工作区。
|
||||
- 阻止符号链接跳出工作区。
|
||||
- 阻止系统目录。
|
||||
- 阻止危险文件类型。
|
||||
- 阻止敏感文件默认读取。
|
||||
- 限制写入行为。
|
||||
- 限制删除行为。
|
||||
|
||||
建议接口:
|
||||
|
||||
```cpp
|
||||
class WorkspaceAccessPolicy
|
||||
{
|
||||
public:
|
||||
explicit WorkspaceAccessPolicy(const QString &workspaceRoot);
|
||||
|
||||
QString workspaceRoot() const;
|
||||
QString normalizePath(const QString &path) const;
|
||||
QString toAbsolutePath(const QString &relativeOrAbsolutePath) const;
|
||||
QString toRelativePath(const QString &absolutePath) const;
|
||||
|
||||
bool isInsideWorkspace(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool hasSymlinkSegment(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool isSystemPath(const QString &path) const;
|
||||
bool isDangerousSuffix(const QString &path) const;
|
||||
bool isSensitiveFile(const QString &path) const;
|
||||
bool isSupportedTextFile(const QString &path) const;
|
||||
|
||||
bool canReadFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canReadDirectory(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canCreateFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canCreateDirectory(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canWriteFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canMoveFile(const QString &sourcePath, const QString &targetPath, QString *errorMessage = nullptr) const;
|
||||
bool canDeleteToTrash(const QString &path, QString *errorMessage = nullptr) const;
|
||||
};
|
||||
```
|
||||
|
||||
禁止危险后缀:
|
||||
|
||||
```text
|
||||
.exe .bat .cmd .ps1 .vbs .msi .scr .reg .com .pif .lnk
|
||||
```
|
||||
|
||||
敏感文件默认拒绝:
|
||||
|
||||
```text
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa
|
||||
id_ed25519
|
||||
*.pfx
|
||||
*.p12
|
||||
credentials.json
|
||||
secrets.json
|
||||
token.json
|
||||
```
|
||||
|
||||
文件名包含以下关键词也视为敏感:
|
||||
|
||||
```text
|
||||
secret
|
||||
token
|
||||
password
|
||||
credential
|
||||
private
|
||||
apikey
|
||||
api_key
|
||||
```
|
||||
|
||||
## 7. 扫描和索引
|
||||
|
||||
`WorkspaceScanner` 第一版使用 `QDir` + 手动目录栈扫描工作区,后续如需取消、进度回调或更细粒度过滤,再考虑替换为 `QDirIterator` 或异步扫描。
|
||||
|
||||
默认限制:
|
||||
|
||||
- 最大递归深度:8。
|
||||
- 最大文件数量:5000。
|
||||
- 跳过隐藏目录。
|
||||
- 跳过常见产物目录。
|
||||
|
||||
跳过目录:
|
||||
|
||||
```text
|
||||
.git
|
||||
.vs
|
||||
.idea
|
||||
build
|
||||
cmake-build-debug
|
||||
cmake-build-release
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
release_packages
|
||||
logs
|
||||
```
|
||||
|
||||
`WorkspaceIndex` 第一版只做内存索引:
|
||||
|
||||
```cpp
|
||||
class WorkspaceIndex
|
||||
{
|
||||
public:
|
||||
void clear();
|
||||
void setFiles(const QVector<WorkspaceFileInfo> &files);
|
||||
QVector<WorkspaceFileInfo> allFiles() const;
|
||||
QVector<WorkspaceFileInfo> findByName(const QString &keyword, int maxResults) const;
|
||||
QVector<WorkspaceFileInfo> findBySuffix(const QStringList &suffixes, int maxResults) const;
|
||||
QVector<WorkspaceFileInfo> recentFiles(int maxResults) const;
|
||||
};
|
||||
```
|
||||
|
||||
## 8. 工具清单
|
||||
|
||||
### 8.1 只读工具
|
||||
|
||||
`ListFilesTool`
|
||||
|
||||
- 列出工作区内目录。
|
||||
- 最多显示 200 项。
|
||||
- 不递归或浅递归。
|
||||
|
||||
`ReadFileTool`
|
||||
|
||||
- 只读文本文件。
|
||||
- 最大读取 128KB。
|
||||
- 超出截断。
|
||||
- 敏感文件默认拒绝。
|
||||
- 不读二进制文件。
|
||||
|
||||
支持后缀:
|
||||
|
||||
```text
|
||||
.cpp .h .hpp .c .cc .py .js .ts .json .md .txt .ini .xml .yaml .yml .cmake .pro .css .html
|
||||
```
|
||||
|
||||
`SearchFilesTool`
|
||||
|
||||
- 只搜索当前工作区索引。
|
||||
- 最多返回 30 个结果。
|
||||
- 按相关性和最近修改时间排序。
|
||||
|
||||
`SearchTextTool`
|
||||
|
||||
- 最多扫描 1000 个文本文件。
|
||||
- 单文件最大读取 128KB。
|
||||
- 最多返回 50 条匹配。
|
||||
- 每条显示文件路径、行号、片段。
|
||||
|
||||
### 8.2 打开工具
|
||||
|
||||
`OpenFileTool`
|
||||
|
||||
- 用系统默认程序打开文件。
|
||||
- 只能打开工作区内普通文件。
|
||||
- 危险后缀拒绝。
|
||||
- 敏感文件需要确认或第一版拒绝。
|
||||
|
||||
`OpenDirectoryTool`
|
||||
|
||||
- 打开工作区内目录。
|
||||
- 工作区外路径拒绝。
|
||||
|
||||
`RevealInExplorerTool`
|
||||
|
||||
- 第一版可以先打开所在文件夹。
|
||||
- 如果后续使用 `explorer.exe /select`,必须固定程序路径和参数,不接受聊天文本命令。
|
||||
|
||||
### 8.3 写入工具
|
||||
|
||||
`CreateFileTool`
|
||||
|
||||
- 只能在工作区内创建。
|
||||
- 目标不能已存在。
|
||||
- 文件名必须合法。
|
||||
- 危险后缀拒绝。
|
||||
- 必须确认。
|
||||
|
||||
`CreateDirectoryTool`
|
||||
|
||||
- 只能在工作区内创建。
|
||||
- 目标不能已存在。
|
||||
- 必须确认。
|
||||
|
||||
`WriteFileTool`
|
||||
|
||||
- 修改已有文本文件前自动备份。
|
||||
- 禁止覆盖二进制文件。
|
||||
- 禁止写敏感文件。
|
||||
- 禁止写危险后缀。
|
||||
- 必须确认。
|
||||
|
||||
备份目录:
|
||||
|
||||
```text
|
||||
<workspaceRoot>/.desktoppet/backups/
|
||||
```
|
||||
|
||||
备份命名:
|
||||
|
||||
```text
|
||||
relative_path.replace("/", "__") + "." + yyyyMMdd-HHmmss + ".bak"
|
||||
```
|
||||
|
||||
`CopyFileTool`
|
||||
|
||||
- 只复制单文件。
|
||||
- 源和目标都必须在工作区内。
|
||||
- 不覆盖。
|
||||
- 必须确认。
|
||||
|
||||
`MoveFileTool`
|
||||
|
||||
- 只移动单文件。
|
||||
- 源和目标都必须在工作区内。
|
||||
- 不覆盖。
|
||||
- 必须确认。
|
||||
|
||||
`RenameFileTool`
|
||||
|
||||
- 只允许同目录重命名。
|
||||
- 文件名不能包含路径分隔符。
|
||||
- 不覆盖。
|
||||
- 必须确认。
|
||||
|
||||
`DeleteToTrashTool`
|
||||
|
||||
- 只移动到系统回收站。
|
||||
- 不永久删除。
|
||||
- 单文件删除第一阶段可支持。
|
||||
- 文件夹删除需要强确认。
|
||||
- 批量删除第一阶段拒绝。
|
||||
|
||||
## 9. WorkspaceAgent
|
||||
|
||||
第一版采用半自动 Agent,不做复杂工具循环。
|
||||
|
||||
推荐流程:
|
||||
|
||||
```text
|
||||
用户输入
|
||||
↓
|
||||
WorkspaceController 判断工作区任务
|
||||
↓
|
||||
程序根据关键词和索引检索相关文件
|
||||
↓
|
||||
读取少量相关文件
|
||||
↓
|
||||
把文件树摘要、相关文件内容、用户问题交给模型
|
||||
↓
|
||||
模型返回分析结果或修改建议
|
||||
↓
|
||||
涉及写操作时生成 WorkspaceActionPlan
|
||||
↓
|
||||
用户确认
|
||||
↓
|
||||
执行工具
|
||||
```
|
||||
|
||||
工作区专用系统提示词:
|
||||
|
||||
```text
|
||||
你是 QtDesktopPet 的本地工作区助手。
|
||||
|
||||
你只能基于用户授权的工作区文件进行分析。
|
||||
你不能假设自己看过未读取的文件。
|
||||
你不能要求访问工作区外路径。
|
||||
你不能执行系统命令。
|
||||
你不能生成危险操作指令。
|
||||
涉及创建、修改、移动、重命名、删除文件时,必须先生成清晰的操作计划,等待用户确认。
|
||||
涉及敏感文件、密钥、凭证时,必须提醒风险。
|
||||
回答代码问题时要引用具体文件路径。
|
||||
当信息不足时,说明还需要读取哪些文件。
|
||||
```
|
||||
|
||||
后续可升级为工具循环,但第一版不做。
|
||||
|
||||
## 10. WorkspaceController
|
||||
|
||||
建议接口:
|
||||
|
||||
```cpp
|
||||
class WorkspaceController
|
||||
{
|
||||
public:
|
||||
explicit WorkspaceController();
|
||||
|
||||
bool hasWorkspace() const;
|
||||
QString workspaceRoot() const;
|
||||
|
||||
bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr);
|
||||
void closeWorkspace();
|
||||
|
||||
WorkspaceToolResult scanWorkspace();
|
||||
WorkspaceToolResult handleMessage(const QString &message);
|
||||
|
||||
bool hasPendingPlan() const;
|
||||
WorkspaceActionPlan pendingPlan() const;
|
||||
WorkspaceToolResult confirmPendingPlan();
|
||||
void cancelPendingPlan();
|
||||
|
||||
private:
|
||||
WorkspaceSession m_session;
|
||||
WorkspaceAgent m_agent;
|
||||
std::optional<WorkspaceActionPlan> m_pendingPlan;
|
||||
};
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- `handleMessage()` 不直接执行高风险写操作。
|
||||
- 需要确认时保存到 `m_pendingPlan`。
|
||||
- UI 调用 `confirmPendingPlan()` 后才执行。
|
||||
- 所有错误通过 `WorkspaceToolResult` 返回,不在工具层弹窗。
|
||||
|
||||
## 11. UI 接入
|
||||
|
||||
聊天入口支持:
|
||||
|
||||
```text
|
||||
打开工作区
|
||||
选择工作区
|
||||
当前工作区
|
||||
扫描工作区
|
||||
分析这个项目
|
||||
搜索当前工作区
|
||||
读取 main.cpp
|
||||
打开第一个
|
||||
```
|
||||
|
||||
如果没有选择工作区,提示用户选择目录。
|
||||
|
||||
气泡只显示短状态:
|
||||
|
||||
```text
|
||||
已打开工作区。
|
||||
正在扫描文件。
|
||||
找到了 12 个相关文件。
|
||||
需要你确认修改计划。
|
||||
操作已完成。
|
||||
```
|
||||
|
||||
复杂结果不全部塞进气泡,当前通过独立 Agent 窗口展示。
|
||||
|
||||
当前独立 Agent 窗口可显示:
|
||||
|
||||
- 当前工作区路径。
|
||||
- 文件树。
|
||||
- 搜索结果。
|
||||
- 已读取文件。
|
||||
- AI 分析结果。
|
||||
- 待确认操作计划。
|
||||
- 执行结果。
|
||||
- 最近操作历史。
|
||||
|
||||
## 12. 意图分发
|
||||
|
||||
后续建议新增:
|
||||
|
||||
```cpp
|
||||
enum class UserIntentType
|
||||
{
|
||||
Chat,
|
||||
Reminder,
|
||||
Weather,
|
||||
Workspace,
|
||||
FileOperation,
|
||||
LaunchApp
|
||||
};
|
||||
```
|
||||
|
||||
意图原则:
|
||||
|
||||
- `Reminder` 保持最高优先级。
|
||||
- 明确天气问答走 `Weather`。
|
||||
- 明确工作区、代码、项目分析走 `Workspace`。
|
||||
- 当前阶段,未明确“工作区/项目/代码”且没有工作区文件名特征的普通文件读写请求仍可走旧 `FileOperation`。
|
||||
- 明确应用名启动走 `LaunchApp`。
|
||||
- 其余走 `Chat` 或输入框联网模式。
|
||||
|
||||
不能用宽关键词简单吞掉所有请求:
|
||||
|
||||
- “打开微信”应走 `LaunchApp`。
|
||||
- “打开文件夹”这种泛请求不走 `Workspace`;“打开 docs / 打开这个项目里的 docs”走 `Workspace`。
|
||||
- “搜索 Qt 最新版本”不应走工作区搜索。
|
||||
- “搜索这个项目里的 main”应走工作区搜索。
|
||||
|
||||
## 13. 迁移步骤
|
||||
|
||||
1. 新增 `src/workspace/`,不改旧 `src/fileops/`。
|
||||
2. CMake 登记 workspace 新文件。
|
||||
3. `PetWindow` 增加 `WorkspaceController` 成员。
|
||||
4. 新增工作区打开、扫描、搜索、读取入口。
|
||||
5. v1b 支持打开和定位;写操作计划和确认框从后续阶段开始。
|
||||
6. 新模块覆盖旧文件操作能力后,`handleFileOperationChatMessage()` 转发到 `WorkspaceController`。
|
||||
7. 确认无旧引用后删除 `src/fileops/`。
|
||||
8. 更新 CMake、README、测试文档。
|
||||
|
||||
删除旧文件前必须执行:
|
||||
|
||||
```text
|
||||
rg "FileOperationManager|FileSandbox|FileBackupManager|src/fileops|FileOperation"
|
||||
```
|
||||
|
||||
确认没有有效代码引用后再删。
|
||||
|
||||
## 14. 日志要求
|
||||
|
||||
允许记录:
|
||||
|
||||
- 打开工作区路径。
|
||||
- 扫描文件数量。
|
||||
- 执行的工具名称。
|
||||
- 操作是否成功。
|
||||
- 错误摘要。
|
||||
|
||||
禁止记录:
|
||||
|
||||
- API Key。
|
||||
- Authorization Header。
|
||||
- 完整敏感文件内容。
|
||||
- `.env` 内容。
|
||||
- 密钥文件内容。
|
||||
- 用户完整隐私文本。
|
||||
|
||||
使用项目已有 `Logger`,不要新增零散调试输出。
|
||||
|
||||
## 15. 测试清单
|
||||
|
||||
### 工作区选择
|
||||
|
||||
- 选择普通项目目录。
|
||||
- 选择空目录。
|
||||
- 选择不存在目录。
|
||||
- 选择 `C:/Windows`,应拒绝。
|
||||
- 选择 `Program Files`,应拒绝。
|
||||
- 选择中文路径。
|
||||
- 选择包含空格的路径。
|
||||
|
||||
### 扫描
|
||||
|
||||
- 扫描普通 Qt 项目。
|
||||
- 扫描包含 build 目录的项目。
|
||||
- 扫描包含 `.git` 的项目。
|
||||
- 扫描大量文件目录。
|
||||
- 扫描中文文件名。
|
||||
- 扫描深层目录。
|
||||
|
||||
要求:
|
||||
|
||||
- 不崩溃。
|
||||
- 不扫系统目录。
|
||||
- 不进入跳过目录。
|
||||
- 结果数量受限制。
|
||||
|
||||
### 搜索文件
|
||||
|
||||
- 搜索 `main`。
|
||||
- 搜索 `.cpp`。
|
||||
- 搜索 `README`。
|
||||
- 搜索不存在文件。
|
||||
- 搜索中文文件名。
|
||||
|
||||
要求:
|
||||
|
||||
- 最多返回 30 条。
|
||||
- 显示相对路径。
|
||||
- 结果可用于后续“打开第几个”。
|
||||
|
||||
### 搜索文本
|
||||
|
||||
- 搜索 `class`。
|
||||
- 搜索 `include`。
|
||||
- 搜索中文关键词。
|
||||
- 搜索不存在关键词。
|
||||
- 搜索超大文件。
|
||||
|
||||
要求:
|
||||
|
||||
- 显示路径、行号、片段。
|
||||
- 跳过二进制文件。
|
||||
- 跳过超大文件。
|
||||
- 不读取敏感文件。
|
||||
|
||||
### 读取文件
|
||||
|
||||
- 读取 `.cpp`。
|
||||
- 读取 `.md`。
|
||||
- 读取 `.json`。
|
||||
- 读取图片应拒绝。
|
||||
- 读取 `.exe` 应拒绝。
|
||||
- 读取 `.env` 应拒绝或强确认。
|
||||
- 读取工作区外文件应拒绝。
|
||||
|
||||
### 创建文件
|
||||
|
||||
- 在工作区创建 `test.txt`。
|
||||
- 创建已存在文件应拒绝。
|
||||
- 创建非法文件名应拒绝。
|
||||
- 创建 `.bat` 应拒绝。
|
||||
- 创建到工作区外应拒绝。
|
||||
|
||||
### 修改文件
|
||||
|
||||
- 修改普通 `.txt`。
|
||||
- 修改 `.cpp`。
|
||||
- 修改前生成备份。
|
||||
- 修改二进制文件应拒绝。
|
||||
- 修改敏感文件应拒绝。
|
||||
|
||||
要求:
|
||||
|
||||
- 必须确认。
|
||||
- 必须备份。
|
||||
- 失败不能破坏原文件。
|
||||
|
||||
### 移动和重命名
|
||||
|
||||
- 移动普通文件。
|
||||
- 移动到已存在目标应拒绝。
|
||||
- 重命名普通文件。
|
||||
- 重命名为非法文件名应拒绝。
|
||||
- 移动到工作区外应拒绝。
|
||||
|
||||
### 删除到回收站
|
||||
|
||||
- 删除普通文件到回收站。
|
||||
- 删除文件夹到回收站。
|
||||
- 删除工作区外文件应拒绝。
|
||||
- 永久删除请求应拒绝。
|
||||
- 批量删除请求第一阶段应拒绝。
|
||||
|
||||
### 对话上下文
|
||||
|
||||
```text
|
||||
用户:搜索 README
|
||||
用户:打开第一个
|
||||
|
||||
用户:搜索 fileops
|
||||
用户:读取第二个
|
||||
|
||||
用户:找一下包含 FileOperationManager 的文件
|
||||
用户:总结这些文件的作用
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 系统能记住上一轮搜索结果。
|
||||
- “第一个”“第二个”能正确解析。
|
||||
- 上下文过期后要求用户重新选择。
|
||||
|
||||
## 16. 静态推演要求
|
||||
|
||||
每完成一个阶段,需要记录:
|
||||
|
||||
- 入口路径。
|
||||
- 业务模块调用链。
|
||||
- 路径校验点。
|
||||
- 用户确认点。
|
||||
- 失败回滚策略。
|
||||
- 不支持能力的拒绝话术。
|
||||
- 对提醒、天气、联网模式、应用启动的回归影响。
|
||||
|
||||
## 17. 最终验收标准
|
||||
|
||||
本轮重构完成后至少达到:
|
||||
|
||||
1. 用户可以选择一个本地项目目录作为工作区。
|
||||
2. 程序可以扫描工作区并建立文件索引。
|
||||
3. 用户可以通过对话搜索文件。
|
||||
4. 用户可以通过对话读取和分析文本文件。
|
||||
5. 用户可以要求分析项目结构。
|
||||
6. 用户可以创建文本文件和文件夹。
|
||||
7. 用户可以移动、重命名、删除到回收站。
|
||||
8. 所有写操作必须展示计划并等待确认。
|
||||
9. 所有路径必须限制在工作区内。
|
||||
10. 不允许永久删除。
|
||||
11. 不允许执行脚本或命令。
|
||||
12. 不允许默认读取敏感文件。
|
||||
13. 旧文件操作功能被新 Workspace 系统替代。
|
||||
14. 项目可以由用户手动构建通过。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 | 不直接破坏仓库内默认角色包 |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,6 +9,7 @@ enum class CommandDispatchAction
|
||||
Chat,
|
||||
Reminder,
|
||||
Weather,
|
||||
Workspace,
|
||||
FileOperation,
|
||||
LaunchApp,
|
||||
UnsupportedTool,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -7,6 +7,7 @@ enum class UserIntentType
|
||||
Chat,
|
||||
Reminder,
|
||||
Weather,
|
||||
Workspace,
|
||||
FileOperation,
|
||||
LaunchApp,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#include "GlobalHotkeyManager.h"
|
||||
|
||||
#include "../util/Logger.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QKeyCombination>
|
||||
#include <QKeySequence>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <utility>
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int HotkeyId = 0x4450;
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
bool parseHotkey(const QString &text, UINT *modifiers, UINT *virtualKey, QString *errorMessage)
|
||||
{
|
||||
QKeySequence sequence = QKeySequence::fromString(text.trimmed(), QKeySequence::PortableText);
|
||||
if (sequence.isEmpty())
|
||||
{
|
||||
sequence = QKeySequence::fromString(text.trimmed(), QKeySequence::NativeText);
|
||||
}
|
||||
if (sequence.isEmpty() || sequence.count() != 1)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("快捷键为空或包含多个按键。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QKeyCombination combination = sequence[0];
|
||||
const Qt::KeyboardModifiers qtModifiers = combination.keyboardModifiers();
|
||||
UINT nativeModifiers = 0;
|
||||
if ((qtModifiers & Qt::AltModifier) != Qt::NoModifier)
|
||||
{
|
||||
nativeModifiers |= MOD_ALT;
|
||||
}
|
||||
if ((qtModifiers & Qt::ControlModifier) != Qt::NoModifier)
|
||||
{
|
||||
nativeModifiers |= MOD_CONTROL;
|
||||
}
|
||||
if ((qtModifiers & Qt::ShiftModifier) != Qt::NoModifier)
|
||||
{
|
||||
nativeModifiers |= MOD_SHIFT;
|
||||
}
|
||||
if ((qtModifiers & Qt::MetaModifier) != Qt::NoModifier)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("暂不支持包含 Win/Meta 的全局快捷键。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (nativeModifiers == 0)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("快捷键必须包含 Ctrl、Alt 或 Shift。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const int key = combination.key();
|
||||
UINT nativeKey = 0;
|
||||
if (key >= Qt::Key_A && key <= Qt::Key_Z)
|
||||
{
|
||||
nativeKey = static_cast<UINT>('A' + key - Qt::Key_A);
|
||||
}
|
||||
else if (key >= Qt::Key_0 && key <= Qt::Key_9)
|
||||
{
|
||||
nativeKey = static_cast<UINT>('0' + key - Qt::Key_0);
|
||||
}
|
||||
else if (key >= Qt::Key_F1 && key <= Qt::Key_F12)
|
||||
{
|
||||
nativeKey = static_cast<UINT>(VK_F1 + key - Qt::Key_F1);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("快捷键主键只支持字母、数字或 F1-F12。");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (modifiers != nullptr)
|
||||
{
|
||||
*modifiers = nativeModifiers;
|
||||
}
|
||||
if (virtualKey != nullptr)
|
||||
{
|
||||
*virtualKey = nativeKey;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
GlobalHotkeyManager::GlobalHotkeyManager() = default;
|
||||
|
||||
GlobalHotkeyManager::~GlobalHotkeyManager()
|
||||
{
|
||||
unregisterHotkey();
|
||||
}
|
||||
|
||||
bool GlobalHotkeyManager::registerHotkey(const QString &sequence, QString *errorMessage)
|
||||
{
|
||||
unregisterHotkey();
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
UINT modifiers = 0;
|
||||
UINT virtualKey = 0;
|
||||
if (!parseHotkey(sequence, &modifiers, &virtualKey, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (QCoreApplication::instance() != nullptr && !m_filterInstalled)
|
||||
{
|
||||
QCoreApplication::instance()->installNativeEventFilter(this);
|
||||
m_filterInstalled = true;
|
||||
}
|
||||
|
||||
if (!RegisterHotKey(nullptr, HotkeyId, modifiers, virtualKey))
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("快捷键注册失败,可能已被其他程序占用。");
|
||||
}
|
||||
Logger::warning(QStringLiteral("Failed to register global hotkey: %1").arg(sequence));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_registered = true;
|
||||
m_sequence = sequence;
|
||||
return true;
|
||||
#else
|
||||
Q_UNUSED(sequence);
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = QStringLiteral("当前平台暂不支持全局快捷键。");
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void GlobalHotkeyManager::unregisterHotkey()
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
if (m_registered)
|
||||
{
|
||||
UnregisterHotKey(nullptr, HotkeyId);
|
||||
}
|
||||
#endif
|
||||
|
||||
m_registered = false;
|
||||
m_sequence.clear();
|
||||
if (m_filterInstalled && QCoreApplication::instance() != nullptr)
|
||||
{
|
||||
QCoreApplication::instance()->removeNativeEventFilter(this);
|
||||
m_filterInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool GlobalHotkeyManager::isRegistered() const
|
||||
{
|
||||
return m_registered;
|
||||
}
|
||||
|
||||
QString GlobalHotkeyManager::sequence() const
|
||||
{
|
||||
return m_sequence;
|
||||
}
|
||||
|
||||
void GlobalHotkeyManager::setActivatedCallback(ActivatedCallback callback)
|
||||
{
|
||||
m_callback = std::move(callback);
|
||||
}
|
||||
|
||||
bool GlobalHotkeyManager::nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result)
|
||||
{
|
||||
Q_UNUSED(eventType);
|
||||
Q_UNUSED(result);
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
MSG *msg = static_cast<MSG *>(message);
|
||||
if (msg != nullptr && msg->message == WM_HOTKEY && static_cast<int>(msg->wParam) == HotkeyId)
|
||||
{
|
||||
if (m_callback)
|
||||
{
|
||||
m_callback();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
Q_UNUSED(message);
|
||||
#endif
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractNativeEventFilter>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class GlobalHotkeyManager : public QAbstractNativeEventFilter
|
||||
{
|
||||
public:
|
||||
using ActivatedCallback = std::function<void()>;
|
||||
|
||||
GlobalHotkeyManager();
|
||||
~GlobalHotkeyManager() override;
|
||||
|
||||
bool registerHotkey(const QString &sequence, QString *errorMessage = nullptr);
|
||||
void unregisterHotkey();
|
||||
bool isRegistered() const;
|
||||
QString sequence() const;
|
||||
void setActivatedCallback(ActivatedCallback callback);
|
||||
|
||||
bool nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result) override;
|
||||
|
||||
private:
|
||||
bool m_registered = false;
|
||||
bool m_filterInstalled = false;
|
||||
QString m_sequence;
|
||||
ActivatedCallback m_callback;
|
||||
};
|
||||
@@ -85,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)
|
||||
|
||||
@@ -19,6 +19,7 @@ private:
|
||||
void showPetWindow();
|
||||
void hidePetWindow();
|
||||
void togglePetWindowVisibility();
|
||||
void showWorkspacePanel();
|
||||
void openSettings();
|
||||
void quitApplication();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPushButton>
|
||||
#include <QSignalBlocker>
|
||||
#include <QTextEdit>
|
||||
|
||||
#include <utility>
|
||||
@@ -106,6 +107,12 @@ ChatInputDialog::ChatInputDialog(int maxLength, QWidget *parent)
|
||||
connect(m_textEdit, &QTextEdit::textChanged, this, [this]() {
|
||||
updateCounter();
|
||||
});
|
||||
connect(m_webToggleCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
if (m_webToggleChangedCallback)
|
||||
{
|
||||
m_webToggleChangedCallback(checked);
|
||||
}
|
||||
});
|
||||
|
||||
auto *panel = new QFrame(this);
|
||||
panel->setObjectName(QStringLiteral("InputPanel"));
|
||||
@@ -143,6 +150,7 @@ void ChatInputDialog::setWebEnabled(bool enabled)
|
||||
{
|
||||
if (m_webToggleCheckBox)
|
||||
{
|
||||
const QSignalBlocker blocker(m_webToggleCheckBox);
|
||||
m_webToggleCheckBox->setChecked(enabled);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +162,7 @@ void ChatInputDialog::setWebToggleAvailable(bool available, const QString &toolT
|
||||
return;
|
||||
}
|
||||
|
||||
const QSignalBlocker blocker(m_webToggleCheckBox);
|
||||
m_webToggleCheckBox->setEnabled(available);
|
||||
if (!available)
|
||||
{
|
||||
@@ -169,6 +178,11 @@ void ChatInputDialog::setSubmitCallback(SubmitCallback callback)
|
||||
m_submitCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void ChatInputDialog::setWebToggleChangedCallback(WebToggleChangedCallback callback)
|
||||
{
|
||||
m_webToggleChangedCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void ChatInputDialog::showAt(const QPoint &anchorPosition)
|
||||
{
|
||||
move(anchorPosition.x() - width() / 2, anchorPosition.y() - height() / 2);
|
||||
|
||||
@@ -17,6 +17,7 @@ class ChatInputDialog : public QDialog
|
||||
{
|
||||
public:
|
||||
using SubmitCallback = std::function<bool(const QString &, bool)>;
|
||||
using WebToggleChangedCallback = std::function<void(bool)>;
|
||||
|
||||
explicit ChatInputDialog(int maxLength, QWidget *parent = nullptr);
|
||||
|
||||
@@ -25,6 +26,7 @@ public:
|
||||
void setWebEnabled(bool enabled);
|
||||
void setWebToggleAvailable(bool available, const QString &toolTip = QString());
|
||||
void setSubmitCallback(SubmitCallback callback);
|
||||
void setWebToggleChangedCallback(WebToggleChangedCallback callback);
|
||||
void showAt(const QPoint &anchorPosition);
|
||||
|
||||
protected:
|
||||
@@ -48,6 +50,7 @@ private:
|
||||
QCheckBox *m_webToggleCheckBox = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
SubmitCallback m_submitCallback;
|
||||
WebToggleChangedCallback m_webToggleChangedCallback;
|
||||
QPoint m_dragStartPosition;
|
||||
QPoint m_dragWindowPosition;
|
||||
int m_maxLength = 0;
|
||||
|
||||
+522
-1
@@ -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 <QAction>
|
||||
#include <QApplication>
|
||||
@@ -57,6 +61,7 @@
|
||||
#include <QSet>
|
||||
#include <QShowEvent>
|
||||
#include <QStringList>
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
|
||||
@@ -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<WebCitation> &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<ConversationManager>())
|
||||
, m_conversationStore(std::make_unique<ConversationStore>(ConfigManager().conversationHistoryPath()))
|
||||
, m_fileOperationManager(std::make_unique<FileOperationManager>())
|
||||
, m_workspaceController(std::make_unique<WorkspaceController>())
|
||||
, m_workspaceAgentWindow(std::make_unique<WorkspaceAgentWindow>())
|
||||
, m_globalHotkeyManager(std::make_unique<GlobalHotkeyManager>())
|
||||
, m_appLaunchManager(std::make_unique<AppLaunchManager>())
|
||||
, m_notificationDispatcher(std::make_unique<NotificationDispatcher>())
|
||||
, m_reminderManager(std::make_unique<ReminderManager>())
|
||||
@@ -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<QWidget *>(m_workspaceAgentWindow.get())
|
||||
: static_cast<QWidget *>(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<QWidget *>(m_workspaceAgentWindow.get())
|
||||
: static_cast<QWidget *>(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<LLMProvider> 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<PetWindow> 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
|
||||
|
||||
@@ -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<ConversationManager> m_conversationManager;
|
||||
std::unique_ptr<ConversationStore> m_conversationStore;
|
||||
std::unique_ptr<FileOperationManager> m_fileOperationManager;
|
||||
std::unique_ptr<WorkspaceController> m_workspaceController;
|
||||
std::unique_ptr<WorkspaceAgentWindow> m_workspaceAgentWindow;
|
||||
std::unique_ptr<LLMProvider> m_workspaceAiProvider;
|
||||
std::unique_ptr<GlobalHotkeyManager> m_globalHotkeyManager;
|
||||
std::unique_ptr<AppLaunchManager> m_appLaunchManager;
|
||||
std::unique_ptr<NotificationDispatcher> m_notificationDispatcher;
|
||||
std::unique_ptr<ReminderManager> m_reminderManager;
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
#include <QList>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QKeySequence>
|
||||
#include <QKeyCombination>
|
||||
#include <QKeySequenceEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPair>
|
||||
#include <QPointer>
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
#include "WorkspaceAgentWindow.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QCloseEvent>
|
||||
#include <QDateTime>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeySequence>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QMouseEvent>
|
||||
#include <QPushButton>
|
||||
#include <QSplitter>
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int DefaultWindowWidth = 1180;
|
||||
constexpr int DefaultWindowHeight = 760;
|
||||
constexpr int MinWindowWidth = 960;
|
||||
constexpr int MinWindowHeight = 600;
|
||||
constexpr int MaxTextMatchesInSidebar = 80;
|
||||
constexpr int MaxTextMatchesInInspector = 20;
|
||||
|
||||
QString htmlEscaped(QString text)
|
||||
{
|
||||
return text.toHtmlEscaped().replace(QStringLiteral("\n"), QStringLiteral("<br>"));
|
||||
}
|
||||
|
||||
QString chatBubbleHtml(
|
||||
const QString &role,
|
||||
const QString &content,
|
||||
Qt::Alignment alignment,
|
||||
const QString &backgroundColor,
|
||||
const QString &borderColor,
|
||||
const QString &roleColor,
|
||||
const QString &contentColor)
|
||||
{
|
||||
const QString horizontalAlignment = alignment == Qt::AlignRight
|
||||
? QStringLiteral("right")
|
||||
: QStringLiteral("left");
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"margin-top:14px; margin-bottom:18px;\">"
|
||||
"<tr><td align=\"%1\">"
|
||||
"<table width=\"76%\" align=\"%1\" cellspacing=\"0\" cellpadding=\"0\" bgcolor=\"%2\" "
|
||||
"style=\"background:%2; border:1px solid %3; border-radius:8px;\">"
|
||||
"<tr><td style=\"padding:12px 14px;\">"
|
||||
"<div style=\"color:%4; font-size:12px; font-weight:700; margin-bottom:7px;\">%5</div>"
|
||||
"<div style=\"color:%6; font-size:13px; line-height:1.55; white-space:pre-wrap;\">%7</div>"
|
||||
"</td></tr></table>"
|
||||
"</td></tr></table>"
|
||||
"<div style=\"margin-bottom:12px;\"><br></div>")
|
||||
.arg(horizontalAlignment)
|
||||
.arg(backgroundColor)
|
||||
.arg(borderColor)
|
||||
.arg(roleColor)
|
||||
.arg(htmlEscaped(role))
|
||||
.arg(contentColor)
|
||||
.arg(htmlEscaped(content));
|
||||
}
|
||||
|
||||
QString fileTypeText(const WorkspaceFileInfo &file)
|
||||
{
|
||||
if (file.isDirectory)
|
||||
{
|
||||
return QStringLiteral("目录");
|
||||
}
|
||||
if (file.isTextFile)
|
||||
{
|
||||
return QStringLiteral("文本文件");
|
||||
}
|
||||
return QStringLiteral("文件");
|
||||
}
|
||||
|
||||
QString compactPath(const WorkspaceFileInfo &file)
|
||||
{
|
||||
return file.relativePath + (file.isDirectory ? QStringLiteral(" [目录]") : QString());
|
||||
}
|
||||
|
||||
QFrame *panelFrame(QWidget *parent)
|
||||
{
|
||||
auto *frame = new QFrame(parent);
|
||||
frame->setObjectName(QStringLiteral("AgentPanel"));
|
||||
auto *layout = new QVBoxLayout(frame);
|
||||
layout->setContentsMargins(12, 12, 12, 12);
|
||||
layout->setSpacing(8);
|
||||
return frame;
|
||||
}
|
||||
|
||||
QLabel *sectionTitle(const QString &text, QWidget *parent)
|
||||
{
|
||||
auto *label = new QLabel(text, parent);
|
||||
label->setObjectName(QStringLiteral("SectionTitle"));
|
||||
return label;
|
||||
}
|
||||
|
||||
QTextEdit *readOnlyTextEdit(QWidget *parent)
|
||||
{
|
||||
auto *edit = new QTextEdit(parent);
|
||||
edit->setReadOnly(true);
|
||||
edit->setFrameShape(QFrame::NoFrame);
|
||||
edit->setLineWrapMode(QTextEdit::WidgetWidth);
|
||||
return edit;
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceAgentWindow::WorkspaceAgentWindow(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("本地工作区 Agent"));
|
||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint);
|
||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||
resize(DefaultWindowWidth, DefaultWindowHeight);
|
||||
setMinimumSize(MinWindowWidth, MinWindowHeight);
|
||||
buildUi();
|
||||
applyStyles();
|
||||
updateSummary();
|
||||
updateResults();
|
||||
updateTextMatches();
|
||||
updateContextFiles();
|
||||
updateInspector();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setSnapshot(const WorkspacePanelSnapshot &snapshot)
|
||||
{
|
||||
m_snapshot = snapshot;
|
||||
updateSummary();
|
||||
updateResults();
|
||||
updateTextMatches();
|
||||
updateContextFiles();
|
||||
updateInspector();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::showWindow()
|
||||
{
|
||||
if (isMinimized())
|
||||
{
|
||||
setWindowState(windowState() & ~Qt::WindowMinimized);
|
||||
}
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::appendUserTask(const QString &message)
|
||||
{
|
||||
if (message.trimmed().isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_chatView->append(chatBubbleHtml(
|
||||
QStringLiteral("用户"),
|
||||
message.trimmed(),
|
||||
Qt::AlignLeft,
|
||||
QStringLiteral("#172554"),
|
||||
QStringLiteral("#2856b6"),
|
||||
QStringLiteral("#93c5fd"),
|
||||
QStringLiteral("#e0f2fe")));
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::appendAgentMessage(const QString &title, const QString &content, bool error)
|
||||
{
|
||||
const QString safeTitle = title.trimmed().isEmpty() ? QStringLiteral("Agent") : title.trimmed();
|
||||
const QString safeContent = content.trimmed().isEmpty() ? QStringLiteral("无详细内容。") : content.trimmed();
|
||||
const QString titleColor = error ? QStringLiteral("#fecaca") : QStringLiteral("#bbf7d0");
|
||||
const QString borderColor = error ? QStringLiteral("#7f1d1d") : QStringLiteral("#166534");
|
||||
const QString backgroundColor = error ? QStringLiteral("#2a1218") : QStringLiteral("#10251a");
|
||||
m_chatView->append(chatBubbleHtml(
|
||||
QStringLiteral("桌宠 · %1").arg(safeTitle),
|
||||
safeContent,
|
||||
Qt::AlignRight,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
titleColor,
|
||||
QStringLiteral("#dbe4f0")));
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setStatusText(const QString &status)
|
||||
{
|
||||
if (m_statusLabel != nullptr)
|
||||
{
|
||||
m_statusLabel->setText(status.trimmed().isEmpty() ? QStringLiteral("就绪") : status.trimmed());
|
||||
}
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setMessageCallback(MessageCallback callback)
|
||||
{
|
||||
m_messageCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setChooseWorkspaceCallback(SimpleCallback callback)
|
||||
{
|
||||
m_chooseWorkspaceCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setRescanCallback(SimpleCallback callback)
|
||||
{
|
||||
m_rescanCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setReadCallback(ResultActionCallback callback)
|
||||
{
|
||||
m_readCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setOpenCallback(ResultActionCallback callback)
|
||||
{
|
||||
m_openCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::setRevealCallback(ResultActionCallback callback)
|
||||
{
|
||||
m_revealCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
hide();
|
||||
event->ignore();
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton && event->position().y() <= 58)
|
||||
{
|
||||
m_dragging = true;
|
||||
m_dragStartPosition = event->globalPosition().toPoint();
|
||||
m_dragWindowPosition = pos();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
QWidget::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
||||
{
|
||||
move(m_dragWindowPosition + (event->globalPosition().toPoint() - m_dragStartPosition));
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
QWidget::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
m_dragging = false;
|
||||
QWidget::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::buildUi()
|
||||
{
|
||||
m_workspaceLabel = new QLabel(this);
|
||||
m_scanLabel = new QLabel(this);
|
||||
m_statusLabel = new QLabel(QStringLiteral("就绪"), this);
|
||||
m_chooseWorkspaceButton = new QPushButton(QStringLiteral("选择工作区"), this);
|
||||
m_rescanButton = new QPushButton(QStringLiteral("重新扫描"), this);
|
||||
m_minimizeButton = new QPushButton(QStringLiteral("−"), this);
|
||||
m_closeButton = new QPushButton(QStringLiteral("×"), this);
|
||||
m_resultList = new QListWidget(this);
|
||||
m_textMatchList = new QListWidget(this);
|
||||
m_contextList = new QListWidget(this);
|
||||
m_chatView = readOnlyTextEdit(this);
|
||||
m_selectedDetailEdit = readOnlyTextEdit(this);
|
||||
m_textMatchDetailEdit = readOnlyTextEdit(this);
|
||||
m_recentReadEdit = readOnlyTextEdit(this);
|
||||
m_planEdit = readOnlyTextEdit(this);
|
||||
m_inputEdit = new QLineEdit(this);
|
||||
m_sendButton = new QPushButton(QStringLiteral("发送"), this);
|
||||
m_readButton = new QPushButton(QStringLiteral("读取"), this);
|
||||
m_openButton = new QPushButton(QStringLiteral("打开"), this);
|
||||
m_revealButton = new QPushButton(QStringLiteral("位置"), this);
|
||||
|
||||
m_workspaceLabel->setObjectName(QStringLiteral("WorkspaceTitle"));
|
||||
m_workspaceLabel->setWordWrap(true);
|
||||
m_scanLabel->setObjectName(QStringLiteral("MetaLabel"));
|
||||
m_statusLabel->setObjectName(QStringLiteral("StatusPill"));
|
||||
m_minimizeButton->setObjectName(QStringLiteral("WindowButton"));
|
||||
m_closeButton->setObjectName(QStringLiteral("CloseButton"));
|
||||
m_minimizeButton->setFixedSize(32, 32);
|
||||
m_closeButton->setFixedSize(32, 32);
|
||||
m_resultList->setObjectName(QStringLiteral("SidebarList"));
|
||||
m_textMatchList->setObjectName(QStringLiteral("SidebarList"));
|
||||
m_contextList->setObjectName(QStringLiteral("SidebarList"));
|
||||
m_resultList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_textMatchList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_contextList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_chatView->setObjectName(QStringLiteral("ChatView"));
|
||||
m_inputEdit->setObjectName(QStringLiteral("TaskInput"));
|
||||
m_inputEdit->setPlaceholderText(QStringLiteral("输入工作区任务,例如:含 class 的文件 / 读取 main.cpp / 分析这个项目"));
|
||||
|
||||
auto *titleTextLayout = new QVBoxLayout;
|
||||
titleTextLayout->setContentsMargins(0, 0, 0, 0);
|
||||
titleTextLayout->setSpacing(4);
|
||||
titleTextLayout->addWidget(m_workspaceLabel);
|
||||
titleTextLayout->addWidget(m_scanLabel);
|
||||
|
||||
auto *topBar = new QHBoxLayout;
|
||||
topBar->setContentsMargins(18, 14, 18, 8);
|
||||
topBar->setSpacing(10);
|
||||
topBar->addLayout(titleTextLayout, 1);
|
||||
topBar->addWidget(m_statusLabel);
|
||||
topBar->addWidget(m_chooseWorkspaceButton);
|
||||
topBar->addWidget(m_rescanButton);
|
||||
topBar->addWidget(m_minimizeButton);
|
||||
topBar->addWidget(m_closeButton);
|
||||
|
||||
auto *fileFrame = panelFrame(this);
|
||||
fileFrame->layout()->addWidget(sectionTitle(QStringLiteral("文件结果"), fileFrame));
|
||||
fileFrame->layout()->addWidget(m_resultList);
|
||||
|
||||
auto *textFrame = panelFrame(this);
|
||||
textFrame->layout()->addWidget(sectionTitle(QStringLiteral("文本命中"), textFrame));
|
||||
textFrame->layout()->addWidget(m_textMatchList);
|
||||
|
||||
auto *contextFrame = panelFrame(this);
|
||||
contextFrame->layout()->addWidget(sectionTitle(QStringLiteral("上下文"), contextFrame));
|
||||
contextFrame->layout()->addWidget(m_contextList);
|
||||
|
||||
auto *sidebarLayout = new QVBoxLayout;
|
||||
sidebarLayout->setContentsMargins(0, 0, 0, 0);
|
||||
sidebarLayout->setSpacing(10);
|
||||
sidebarLayout->addWidget(fileFrame, 3);
|
||||
sidebarLayout->addWidget(textFrame, 2);
|
||||
sidebarLayout->addWidget(contextFrame, 1);
|
||||
auto *sidebar = new QWidget(this);
|
||||
sidebar->setLayout(sidebarLayout);
|
||||
|
||||
auto *chatFrame = panelFrame(this);
|
||||
chatFrame->layout()->addWidget(sectionTitle(QStringLiteral("Agent 对话"), chatFrame));
|
||||
chatFrame->layout()->addWidget(m_chatView);
|
||||
|
||||
auto *inputFrame = new QFrame(this);
|
||||
inputFrame->setObjectName(QStringLiteral("InputPanel"));
|
||||
auto *inputLayout = new QHBoxLayout(inputFrame);
|
||||
inputLayout->setContentsMargins(12, 10, 12, 10);
|
||||
inputLayout->setSpacing(10);
|
||||
inputLayout->addWidget(m_inputEdit, 1);
|
||||
inputLayout->addWidget(m_sendButton);
|
||||
|
||||
auto *centerLayout = new QVBoxLayout;
|
||||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||
centerLayout->setSpacing(10);
|
||||
centerLayout->addWidget(chatFrame, 1);
|
||||
centerLayout->addWidget(inputFrame);
|
||||
auto *center = new QWidget(this);
|
||||
center->setLayout(centerLayout);
|
||||
|
||||
auto *selectedFrame = panelFrame(this);
|
||||
selectedFrame->layout()->addWidget(sectionTitle(QStringLiteral("选中项详情"), selectedFrame));
|
||||
selectedFrame->layout()->addWidget(m_selectedDetailEdit);
|
||||
|
||||
auto *textMatchDetailFrame = panelFrame(this);
|
||||
textMatchDetailFrame->layout()->addWidget(sectionTitle(QStringLiteral("文本命中详情"), textMatchDetailFrame));
|
||||
textMatchDetailFrame->layout()->addWidget(m_textMatchDetailEdit);
|
||||
|
||||
auto *recentReadFrame = panelFrame(this);
|
||||
recentReadFrame->layout()->addWidget(sectionTitle(QStringLiteral("最近读取预览"), recentReadFrame));
|
||||
recentReadFrame->layout()->addWidget(m_recentReadEdit);
|
||||
|
||||
auto *planFrame = panelFrame(this);
|
||||
planFrame->layout()->addWidget(sectionTitle(QStringLiteral("变更计划 / Diff"), planFrame));
|
||||
planFrame->layout()->addWidget(m_planEdit);
|
||||
|
||||
auto *actionLayout = new QHBoxLayout;
|
||||
actionLayout->setContentsMargins(0, 0, 0, 0);
|
||||
actionLayout->setSpacing(8);
|
||||
actionLayout->addWidget(m_readButton);
|
||||
actionLayout->addWidget(m_openButton);
|
||||
actionLayout->addWidget(m_revealButton);
|
||||
|
||||
auto *inspectorLayout = new QVBoxLayout;
|
||||
inspectorLayout->setContentsMargins(0, 0, 0, 0);
|
||||
inspectorLayout->setSpacing(10);
|
||||
inspectorLayout->addWidget(selectedFrame, 2);
|
||||
inspectorLayout->addWidget(textMatchDetailFrame, 2);
|
||||
inspectorLayout->addWidget(recentReadFrame, 3);
|
||||
inspectorLayout->addWidget(planFrame, 2);
|
||||
inspectorLayout->addLayout(actionLayout);
|
||||
auto *inspector = new QWidget(this);
|
||||
inspector->setLayout(inspectorLayout);
|
||||
|
||||
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||
splitter->setObjectName(QStringLiteral("AgentSplitter"));
|
||||
splitter->addWidget(sidebar);
|
||||
splitter->addWidget(center);
|
||||
splitter->addWidget(inspector);
|
||||
splitter->setStretchFactor(0, 0);
|
||||
splitter->setStretchFactor(1, 1);
|
||||
splitter->setStretchFactor(2, 0);
|
||||
splitter->setSizes({290, 560, 330});
|
||||
|
||||
auto *root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(0, 0, 0, 0);
|
||||
root->setSpacing(0);
|
||||
root->addLayout(topBar);
|
||||
root->addWidget(splitter, 1);
|
||||
|
||||
connect(m_chooseWorkspaceButton, &QPushButton::clicked, this, [this]() {
|
||||
if (m_chooseWorkspaceCallback)
|
||||
{
|
||||
m_chooseWorkspaceCallback();
|
||||
}
|
||||
});
|
||||
connect(m_rescanButton, &QPushButton::clicked, this, [this]() {
|
||||
if (m_rescanCallback)
|
||||
{
|
||||
m_rescanCallback();
|
||||
}
|
||||
});
|
||||
connect(m_minimizeButton, &QPushButton::clicked, this, [this]() {
|
||||
showMinimized();
|
||||
});
|
||||
connect(m_closeButton, &QPushButton::clicked, this, [this]() {
|
||||
hide();
|
||||
});
|
||||
connect(m_sendButton, &QPushButton::clicked, this, [this]() {
|
||||
submitInput();
|
||||
});
|
||||
connect(m_inputEdit, &QLineEdit::returnPressed, this, [this]() {
|
||||
submitInput();
|
||||
});
|
||||
connect(m_resultList, &QListWidget::currentRowChanged, this, [this]() {
|
||||
updateInspector();
|
||||
updateActionButtons();
|
||||
});
|
||||
connect(m_textMatchList, &QListWidget::currentRowChanged, this, [this]() {
|
||||
updateInspector();
|
||||
});
|
||||
connect(m_readButton, &QPushButton::clicked, this, [this]() {
|
||||
const int index = selectedResultIndex();
|
||||
if (index >= 0 && m_readCallback)
|
||||
{
|
||||
m_readCallback(index);
|
||||
}
|
||||
});
|
||||
connect(m_openButton, &QPushButton::clicked, this, [this]() {
|
||||
const int index = selectedResultIndex();
|
||||
if (index >= 0 && m_openCallback)
|
||||
{
|
||||
m_openCallback(index);
|
||||
}
|
||||
});
|
||||
connect(m_revealButton, &QPushButton::clicked, this, [this]() {
|
||||
const int index = selectedResultIndex();
|
||||
if (index >= 0 && m_revealCallback)
|
||||
{
|
||||
m_revealCallback(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::applyStyles()
|
||||
{
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QWidget { background:#0b1120; color:#e5e7eb; font-family:'Microsoft YaHei', 'Segoe UI'; }"
|
||||
"QLabel#WorkspaceTitle { color:#f8fafc; font-size:17px; font-weight:700; }"
|
||||
"QLabel#MetaLabel { color:#94a3b8; font-size:12px; }"
|
||||
"QLabel#StatusPill { background:#102a43; border:1px solid #1e3a5f; border-radius:12px; color:#bfdbfe; padding:4px 10px; }"
|
||||
"QLabel#SectionTitle { color:#bfdbfe; font-size:13px; font-weight:700; }"
|
||||
"QFrame#AgentPanel, QFrame#InputPanel { background:#111827; border:1px solid #26364f; border-radius:8px; }"
|
||||
"QListWidget#SidebarList { background:#0f172a; border:1px solid #1f2f46; border-radius:6px; color:#dbe4f0; padding:4px; }"
|
||||
"QListWidget#SidebarList::item { min-height:24px; padding:5px 7px; border-radius:5px; }"
|
||||
"QListWidget#SidebarList::item:selected { background:#1d4ed8; color:#ffffff; }"
|
||||
"QTextEdit { background:#0f172a; border:1px solid #1f2f46; border-radius:6px; color:#dbe4f0; padding:8px; selection-background-color:#2563eb; }"
|
||||
"QTextEdit#ChatView { font-size:13px; padding:12px; }"
|
||||
"QLineEdit#TaskInput { background:#0f172a; border:1px solid #334155; border-radius:7px; color:#f8fafc; padding:8px 10px; min-height:28px; }"
|
||||
"QLineEdit#TaskInput:focus { border-color:#3b82f6; }"
|
||||
"QPushButton { background:#1f2937; border:1px solid #334155; border-radius:7px; color:#e5e7eb; padding:6px 12px; min-height:28px; }"
|
||||
"QPushButton:hover:enabled { background:#26364f; border-color:#475569; }"
|
||||
"QPushButton:disabled { background:#111827; border-color:#1f2937; color:#64748b; }"
|
||||
"QPushButton#WindowButton, QPushButton#CloseButton { min-width:32px; max-width:32px; min-height:32px; max-height:32px; border-radius:16px; padding:0; font-size:18px; }"
|
||||
"QPushButton#CloseButton:hover:enabled { background:#7f1d1d; border-color:#991b1b; }"
|
||||
"QSplitter::handle { background:#0b1120; width:8px; }"));
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::updateSummary()
|
||||
{
|
||||
const QString workspaceText = m_snapshot.workspaceRoot.trimmed().isEmpty()
|
||||
? QStringLiteral("未选择工作区")
|
||||
: m_snapshot.workspaceRoot;
|
||||
const QString scanText = m_snapshot.lastScanAt.isValid()
|
||||
? m_snapshot.lastScanAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))
|
||||
: QStringLiteral("尚未扫描");
|
||||
m_workspaceLabel->setText(QStringLiteral("本地工作区 Agent"));
|
||||
m_scanLabel->setText(QStringLiteral("工作区:%1 索引项:%2 最近扫描:%3")
|
||||
.arg(workspaceText)
|
||||
.arg(m_snapshot.indexedItemCount)
|
||||
.arg(scanText));
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::updateResults()
|
||||
{
|
||||
const int currentRow = m_resultList->currentRow();
|
||||
m_resultList->clear();
|
||||
if (m_snapshot.lastResults.isEmpty())
|
||||
{
|
||||
auto *item = new QListWidgetItem(QStringLiteral("暂无文件结果"));
|
||||
item->setFlags(Qt::NoItemFlags);
|
||||
m_resultList->addItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < m_snapshot.lastResults.size(); ++index)
|
||||
{
|
||||
m_resultList->addItem(QStringLiteral("%1. %2").arg(index + 1).arg(compactPath(m_snapshot.lastResults.at(index))));
|
||||
}
|
||||
if (currentRow >= 0 && currentRow < m_resultList->count())
|
||||
{
|
||||
m_resultList->setCurrentRow(currentRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_resultList->setCurrentRow(0);
|
||||
}
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::updateTextMatches()
|
||||
{
|
||||
const int currentRow = m_textMatchList->currentRow();
|
||||
m_textMatchList->clear();
|
||||
if (m_snapshot.lastTextMatches.isEmpty())
|
||||
{
|
||||
auto *item = new QListWidgetItem(QStringLiteral("暂无文本命中"));
|
||||
item->setFlags(Qt::NoItemFlags);
|
||||
m_textMatchList->addItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInSidebar);
|
||||
for (int i = 0; i < matchCount; ++i)
|
||||
{
|
||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
|
||||
m_textMatchList->addItem(QStringLiteral("%1:%2 %3")
|
||||
.arg(match.relativePath)
|
||||
.arg(match.lineNumber)
|
||||
.arg(match.lineText.simplified().left(80)));
|
||||
}
|
||||
if (m_snapshot.lastTextMatches.size() > matchCount)
|
||||
{
|
||||
auto *item = new QListWidgetItem(QStringLiteral("仅显示前 %1 条命中").arg(matchCount));
|
||||
item->setFlags(Qt::NoItemFlags);
|
||||
m_textMatchList->addItem(item);
|
||||
}
|
||||
if (currentRow >= 0 && currentRow < m_textMatchList->count())
|
||||
{
|
||||
m_textMatchList->setCurrentRow(currentRow);
|
||||
}
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::updateContextFiles()
|
||||
{
|
||||
m_contextList->clear();
|
||||
if (!m_snapshot.hasLastReadFile)
|
||||
{
|
||||
auto *item = new QListWidgetItem(QStringLiteral("暂无最近读取文件"));
|
||||
item->setFlags(Qt::NoItemFlags);
|
||||
m_contextList->addItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
m_contextList->addItem(m_snapshot.lastReadFile.relativePath);
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::updateInspector()
|
||||
{
|
||||
const int index = selectedResultIndex();
|
||||
if (index < 0)
|
||||
{
|
||||
m_selectedDetailEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">请选择左侧文件结果。</div>"));
|
||||
}
|
||||
else
|
||||
{
|
||||
const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index);
|
||||
m_selectedDetailEdit->setHtml(QStringLiteral(
|
||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
||||
"<div style=\"color:#cbd5e1;\">类型:%2<br>大小:%3 字节<br>修改时间:%4<br>绝对路径:%5</div>")
|
||||
.arg(htmlEscaped(compactPath(file)))
|
||||
.arg(fileTypeText(file))
|
||||
.arg(file.sizeBytes)
|
||||
.arg(file.lastModified.isValid() ? file.lastModified.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("未知"))
|
||||
.arg(htmlEscaped(file.absolutePath)));
|
||||
}
|
||||
|
||||
if (!m_snapshot.hasLastReadFile)
|
||||
{
|
||||
m_recentReadEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">暂无最近读取文件。</div>"));
|
||||
}
|
||||
else
|
||||
{
|
||||
m_recentReadEdit->setHtml(QStringLiteral(
|
||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
||||
"<pre style=\"white-space:pre-wrap; color:#dbe4f0; font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px; margin:0;\">%2</pre>")
|
||||
.arg(htmlEscaped(m_snapshot.lastReadFile.relativePath))
|
||||
.arg(m_snapshot.lastReadPreview.toHtmlEscaped()));
|
||||
}
|
||||
|
||||
const int matchRow = m_textMatchList->currentRow();
|
||||
if (matchRow >= 0 && matchRow < m_snapshot.lastTextMatches.size())
|
||||
{
|
||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(matchRow);
|
||||
m_textMatchDetailEdit->setHtml(QStringLiteral(
|
||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1:%2</div>"
|
||||
"<div style=\"color:#dbe4f0; white-space:pre-wrap;\">%3</div>"
|
||||
"<div style=\"margin-top:8px; color:#94a3b8;\">%4</div>")
|
||||
.arg(htmlEscaped(match.relativePath))
|
||||
.arg(match.lineNumber)
|
||||
.arg(htmlEscaped(match.lineText))
|
||||
.arg(htmlEscaped(match.absolutePath)));
|
||||
}
|
||||
else if (m_snapshot.lastTextMatches.isEmpty())
|
||||
{
|
||||
m_textMatchDetailEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">暂无文本搜索命中。</div>"));
|
||||
}
|
||||
else
|
||||
{
|
||||
QString matchHtml;
|
||||
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInInspector);
|
||||
for (int i = 0; i < matchCount; ++i)
|
||||
{
|
||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
|
||||
matchHtml += QStringLiteral("<div style=\"margin-bottom:8px;\"><b style=\"color:#bfdbfe;\">%1:%2</b><br>%3</div>")
|
||||
.arg(htmlEscaped(match.relativePath))
|
||||
.arg(match.lineNumber)
|
||||
.arg(htmlEscaped(match.lineText));
|
||||
}
|
||||
m_textMatchDetailEdit->setHtml(matchHtml);
|
||||
}
|
||||
m_planEdit->setHtml(QStringLiteral("<div style=\"color:#94a3b8;\">v1b 阶段保持只读,没有待确认变更。v1c 写操作会在这里展示计划和 Diff。</div>"));
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::updateActionButtons()
|
||||
{
|
||||
const int index = selectedResultIndex();
|
||||
const bool hasSelection = index >= 0;
|
||||
m_openButton->setEnabled(hasSelection);
|
||||
m_revealButton->setEnabled(hasSelection);
|
||||
m_readButton->setEnabled(hasSelection && !m_snapshot.lastResults.at(index).isDirectory);
|
||||
m_rescanButton->setEnabled(!m_snapshot.workspaceRoot.trimmed().isEmpty());
|
||||
}
|
||||
|
||||
void WorkspaceAgentWindow::submitInput()
|
||||
{
|
||||
const QString message = m_inputEdit->text().trimmed();
|
||||
if (message.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_messageCallback && m_messageCallback(message))
|
||||
{
|
||||
m_inputEdit->clear();
|
||||
}
|
||||
}
|
||||
|
||||
int WorkspaceAgentWindow::selectedResultIndex() const
|
||||
{
|
||||
const int row = m_resultList->currentRow();
|
||||
if (row < 0 || row >= m_snapshot.lastResults.size())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
#include "../workspace/WorkspaceTypes.h"
|
||||
|
||||
#include <QPoint>
|
||||
#include <QWidget>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QTextEdit;
|
||||
class QCloseEvent;
|
||||
class QMouseEvent;
|
||||
|
||||
class WorkspaceAgentWindow : public QWidget
|
||||
{
|
||||
public:
|
||||
using MessageCallback = std::function<bool(const QString &)>;
|
||||
using SimpleCallback = std::function<void()>;
|
||||
using ResultActionCallback = std::function<void(int)>;
|
||||
|
||||
explicit WorkspaceAgentWindow(QWidget *parent = nullptr);
|
||||
|
||||
void setSnapshot(const WorkspacePanelSnapshot &snapshot);
|
||||
void showWindow();
|
||||
void appendUserTask(const QString &message);
|
||||
void appendAgentMessage(const QString &title, const QString &content, bool error = false);
|
||||
void setStatusText(const QString &status);
|
||||
|
||||
void setMessageCallback(MessageCallback callback);
|
||||
void setChooseWorkspaceCallback(SimpleCallback callback);
|
||||
void setRescanCallback(SimpleCallback callback);
|
||||
void setReadCallback(ResultActionCallback callback);
|
||||
void setOpenCallback(ResultActionCallback callback);
|
||||
void setRevealCallback(ResultActionCallback callback);
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
|
||||
private:
|
||||
void buildUi();
|
||||
void applyStyles();
|
||||
void updateSummary();
|
||||
void updateResults();
|
||||
void updateTextMatches();
|
||||
void updateContextFiles();
|
||||
void updateInspector();
|
||||
void updateActionButtons();
|
||||
void submitInput();
|
||||
int selectedResultIndex() const;
|
||||
|
||||
QLabel *m_workspaceLabel = nullptr;
|
||||
QLabel *m_scanLabel = nullptr;
|
||||
QLabel *m_statusLabel = nullptr;
|
||||
QPushButton *m_chooseWorkspaceButton = nullptr;
|
||||
QPushButton *m_rescanButton = nullptr;
|
||||
QPushButton *m_minimizeButton = nullptr;
|
||||
QPushButton *m_closeButton = nullptr;
|
||||
QListWidget *m_resultList = nullptr;
|
||||
QListWidget *m_textMatchList = nullptr;
|
||||
QListWidget *m_contextList = nullptr;
|
||||
QTextEdit *m_chatView = nullptr;
|
||||
QTextEdit *m_selectedDetailEdit = nullptr;
|
||||
QTextEdit *m_textMatchDetailEdit = nullptr;
|
||||
QTextEdit *m_recentReadEdit = nullptr;
|
||||
QTextEdit *m_planEdit = nullptr;
|
||||
QLineEdit *m_inputEdit = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
QPushButton *m_readButton = nullptr;
|
||||
QPushButton *m_openButton = nullptr;
|
||||
QPushButton *m_revealButton = nullptr;
|
||||
|
||||
WorkspacePanelSnapshot m_snapshot;
|
||||
MessageCallback m_messageCallback;
|
||||
SimpleCallback m_chooseWorkspaceCallback;
|
||||
SimpleCallback m_rescanCallback;
|
||||
ResultActionCallback m_readCallback;
|
||||
ResultActionCallback m_openCallback;
|
||||
ResultActionCallback m_revealCallback;
|
||||
QPoint m_dragStartPosition;
|
||||
QPoint m_dragWindowPosition;
|
||||
bool m_dragging = false;
|
||||
};
|
||||
@@ -0,0 +1,506 @@
|
||||
#include "WorkspacePanel.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QFrame>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QMouseEvent>
|
||||
#include <QPushButton>
|
||||
#include <QScreen>
|
||||
#include <QSplitter>
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int PanelWidth = 780;
|
||||
constexpr int PanelHeight = 560;
|
||||
constexpr int PanelGap = 16;
|
||||
constexpr int ScreenMargin = 8;
|
||||
constexpr int MaxTextMatchesInDetails = 30;
|
||||
|
||||
int boundedCoordinate(int value, int minimum, int maximum)
|
||||
{
|
||||
if (maximum < minimum)
|
||||
{
|
||||
return minimum;
|
||||
}
|
||||
return qBound(minimum, value, maximum);
|
||||
}
|
||||
|
||||
qint64 overlapArea(const QRect &first, const QRect &second)
|
||||
{
|
||||
const QRect intersection = first.intersected(second);
|
||||
if (intersection.isEmpty())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return static_cast<qint64>(intersection.width()) * static_cast<qint64>(intersection.height());
|
||||
}
|
||||
|
||||
qint64 squaredDistance(const QPoint &first, const QPoint &second)
|
||||
{
|
||||
const qint64 dx = first.x() - second.x();
|
||||
const qint64 dy = first.y() - second.y();
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
QString compactPath(const WorkspaceFileInfo &file)
|
||||
{
|
||||
return file.relativePath + (file.isDirectory ? QStringLiteral(" [目录]") : QString());
|
||||
}
|
||||
|
||||
QString fileTypeText(const WorkspaceFileInfo &file)
|
||||
{
|
||||
if (file.isDirectory)
|
||||
{
|
||||
return QStringLiteral("目录");
|
||||
}
|
||||
if (file.isTextFile)
|
||||
{
|
||||
return QStringLiteral("文本文件");
|
||||
}
|
||||
return QStringLiteral("文件");
|
||||
}
|
||||
|
||||
QString htmlLine(QString text)
|
||||
{
|
||||
return text.toHtmlEscaped().replace(QStringLiteral("\n"), QStringLiteral("<br>"));
|
||||
}
|
||||
|
||||
QFrame *card(QWidget *parent)
|
||||
{
|
||||
auto *frame = new QFrame(parent);
|
||||
frame->setObjectName(QStringLiteral("WorkspaceCard"));
|
||||
auto *layout = new QVBoxLayout(frame);
|
||||
layout->setContentsMargins(14, 12, 14, 12);
|
||||
layout->setSpacing(8);
|
||||
return frame;
|
||||
}
|
||||
|
||||
QLabel *cardTitle(const QString &text, QWidget *parent)
|
||||
{
|
||||
auto *label = new QLabel(text, parent);
|
||||
label->setObjectName(QStringLiteral("CardTitle"));
|
||||
return label;
|
||||
}
|
||||
|
||||
QTextEdit *readOnlyTextEdit(QWidget *parent)
|
||||
{
|
||||
auto *edit = new QTextEdit(parent);
|
||||
edit->setReadOnly(true);
|
||||
edit->setFrameShape(QFrame::NoFrame);
|
||||
edit->setLineWrapMode(QTextEdit::WidgetWidth);
|
||||
return edit;
|
||||
}
|
||||
}
|
||||
|
||||
WorkspacePanel::WorkspacePanel(QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, m_workspaceLabel(new QLabel(this))
|
||||
, m_scanLabel(new QLabel(this))
|
||||
, m_summaryLabel(new QLabel(this))
|
||||
, m_resultList(new QListWidget(this))
|
||||
, m_selectedDetailEdit(readOnlyTextEdit(this))
|
||||
, m_textMatchesEdit(readOnlyTextEdit(this))
|
||||
, m_recentReadEdit(readOnlyTextEdit(this))
|
||||
, m_readButton(new QPushButton(QStringLiteral("读取"), this))
|
||||
, m_openButton(new QPushButton(QStringLiteral("打开"), this))
|
||||
, m_revealButton(new QPushButton(QStringLiteral("位置"), this))
|
||||
, m_closeButton(new QPushButton(QStringLiteral("×"), this))
|
||||
{
|
||||
setWindowTitle(QStringLiteral("本地工作区 Agent"));
|
||||
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
||||
setModal(false);
|
||||
resize(PanelWidth, PanelHeight);
|
||||
|
||||
m_workspaceLabel->setObjectName(QStringLiteral("WorkspaceTitle"));
|
||||
m_workspaceLabel->setWordWrap(true);
|
||||
m_scanLabel->setObjectName(QStringLiteral("MetaLabel"));
|
||||
m_summaryLabel->setObjectName(QStringLiteral("MetaLabel"));
|
||||
m_summaryLabel->setWordWrap(true);
|
||||
m_closeButton->setObjectName(QStringLiteral("TitleCloseButton"));
|
||||
m_closeButton->setFixedSize(30, 30);
|
||||
m_resultList->setObjectName(QStringLiteral("ResultList"));
|
||||
m_resultList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
|
||||
auto *titleLayout = new QHBoxLayout;
|
||||
titleLayout->setContentsMargins(0, 0, 0, 0);
|
||||
titleLayout->setSpacing(10);
|
||||
titleLayout->addWidget(m_workspaceLabel, 1);
|
||||
titleLayout->addWidget(m_closeButton);
|
||||
|
||||
auto *headerLayout = new QVBoxLayout;
|
||||
headerLayout->setSpacing(6);
|
||||
headerLayout->addLayout(titleLayout);
|
||||
headerLayout->addWidget(m_scanLabel);
|
||||
headerLayout->addWidget(m_summaryLabel);
|
||||
|
||||
auto *leftCard = card(this);
|
||||
leftCard->setMinimumWidth(260);
|
||||
leftCard->layout()->addWidget(cardTitle(QStringLiteral("上一轮结果"), leftCard));
|
||||
leftCard->layout()->addWidget(m_resultList);
|
||||
|
||||
auto *selectedCard = card(this);
|
||||
selectedCard->layout()->addWidget(cardTitle(QStringLiteral("选中项"), selectedCard));
|
||||
selectedCard->layout()->addWidget(m_selectedDetailEdit);
|
||||
|
||||
auto *matchesCard = card(this);
|
||||
matchesCard->layout()->addWidget(cardTitle(QStringLiteral("文本命中"), matchesCard));
|
||||
matchesCard->layout()->addWidget(m_textMatchesEdit);
|
||||
|
||||
auto *recentReadCard = card(this);
|
||||
recentReadCard->layout()->addWidget(cardTitle(QStringLiteral("最近读取"), recentReadCard));
|
||||
recentReadCard->layout()->addWidget(m_recentReadEdit);
|
||||
|
||||
auto *rightLayout = new QVBoxLayout;
|
||||
rightLayout->setContentsMargins(0, 0, 0, 0);
|
||||
rightLayout->setSpacing(12);
|
||||
rightLayout->addWidget(selectedCard, 1);
|
||||
rightLayout->addWidget(matchesCard, 2);
|
||||
rightLayout->addWidget(recentReadCard, 2);
|
||||
|
||||
auto *rightContainer = new QWidget(this);
|
||||
rightContainer->setLayout(rightLayout);
|
||||
|
||||
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||
splitter->setObjectName(QStringLiteral("WorkspaceSplitter"));
|
||||
splitter->addWidget(leftCard);
|
||||
splitter->addWidget(rightContainer);
|
||||
splitter->setStretchFactor(0, 0);
|
||||
splitter->setStretchFactor(1, 1);
|
||||
splitter->setSizes({280, 470});
|
||||
|
||||
auto *buttonLayout = new QHBoxLayout;
|
||||
buttonLayout->setSpacing(10);
|
||||
buttonLayout->addWidget(m_readButton);
|
||||
buttonLayout->addWidget(m_openButton);
|
||||
buttonLayout->addWidget(m_revealButton);
|
||||
buttonLayout->addStretch(1);
|
||||
auto *bottomCloseButton = new QPushButton(QStringLiteral("关闭"), this);
|
||||
buttonLayout->addWidget(bottomCloseButton);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(16, 16, 16, 16);
|
||||
layout->setSpacing(14);
|
||||
layout->addLayout(headerLayout);
|
||||
layout->addWidget(splitter, 1);
|
||||
layout->addLayout(buttonLayout);
|
||||
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QDialog { background: #111827; color: #e5e7eb; }"
|
||||
"QLabel#WorkspaceTitle { color: #f8fafc; font-size: 17px; font-weight: 700; }"
|
||||
"QLabel#MetaLabel { color: #93a4bd; font-size: 12px; }"
|
||||
"QLabel#CardTitle { color: #dbeafe; font-size: 13px; font-weight: 700; }"
|
||||
"QFrame#WorkspaceCard { background: #172235; border: 1px solid #2a3a55; border-radius: 8px; }"
|
||||
"QListWidget#ResultList { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 4px; }"
|
||||
"QListWidget#ResultList::item { min-height: 28px; padding: 6px 8px; border-radius: 5px; }"
|
||||
"QListWidget#ResultList::item:selected { background: #1d4ed8; color: #ffffff; }"
|
||||
"QTextEdit { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 8px; selection-background-color: #2563eb; }"
|
||||
"QPushButton {"
|
||||
"background: #1f2d44;"
|
||||
"border: 1px solid #35506f;"
|
||||
"border-radius: 6px;"
|
||||
"padding: 5px 14px;"
|
||||
"min-height: 30px;"
|
||||
"color: #e5e7eb;"
|
||||
"}"
|
||||
"QPushButton:hover:enabled { background: #263b5f; }"
|
||||
"QPushButton:disabled { color: #64748b; background: #162033; border-color: #243247; }"
|
||||
"QPushButton#TitleCloseButton { min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; border-radius: 15px; padding: 0; font-size: 18px; }"
|
||||
"QPushButton#TitleCloseButton:hover:enabled { background: #7f1d1d; border-color: #991b1b; }"
|
||||
"QSplitter::handle { background: transparent; width: 8px; }"));
|
||||
|
||||
connect(m_resultList, &QListWidget::currentRowChanged, this, [this]() {
|
||||
updateDetails();
|
||||
updateActionButtons();
|
||||
});
|
||||
connect(m_readButton, &QPushButton::clicked, this, [this]() {
|
||||
const int index = selectedResultIndex();
|
||||
if (index >= 0 && m_readCallback)
|
||||
{
|
||||
m_readCallback(index);
|
||||
}
|
||||
});
|
||||
connect(m_openButton, &QPushButton::clicked, this, [this]() {
|
||||
const int index = selectedResultIndex();
|
||||
if (index >= 0 && m_openCallback)
|
||||
{
|
||||
m_openCallback(index);
|
||||
}
|
||||
});
|
||||
connect(m_revealButton, &QPushButton::clicked, this, [this]() {
|
||||
const int index = selectedResultIndex();
|
||||
if (index >= 0 && m_revealCallback)
|
||||
{
|
||||
m_revealCallback(index);
|
||||
}
|
||||
});
|
||||
connect(m_closeButton, &QPushButton::clicked, this, &QDialog::hide);
|
||||
connect(bottomCloseButton, &QPushButton::clicked, this, &QDialog::hide);
|
||||
updateDetails();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
void WorkspacePanel::setSnapshot(const WorkspacePanelSnapshot &snapshot)
|
||||
{
|
||||
m_snapshot = snapshot;
|
||||
updateSummary();
|
||||
updateResults();
|
||||
updateDetails();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
void WorkspacePanel::showNear(const QRect &avoidRect)
|
||||
{
|
||||
move(smartTopLeftNear(avoidRect));
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
}
|
||||
|
||||
void WorkspacePanel::setReadCallback(ResultActionCallback callback)
|
||||
{
|
||||
m_readCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspacePanel::setOpenCallback(ResultActionCallback callback)
|
||||
{
|
||||
m_openCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspacePanel::setRevealCallback(ResultActionCallback callback)
|
||||
{
|
||||
m_revealCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void WorkspacePanel::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton && event->position().y() <= 72)
|
||||
{
|
||||
m_dragging = true;
|
||||
m_dragStartPosition = event->globalPosition().toPoint();
|
||||
m_dragWindowPosition = pos();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
QDialog::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void WorkspacePanel::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if (m_dragging && (event->buttons() & Qt::LeftButton))
|
||||
{
|
||||
move(m_dragWindowPosition + (event->globalPosition().toPoint() - m_dragStartPosition));
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
QDialog::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void WorkspacePanel::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
m_dragging = false;
|
||||
QDialog::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
QRect WorkspacePanel::availableGeometryForRect(const QRect &rect) const
|
||||
{
|
||||
const QScreen *screen = QGuiApplication::screenAt(rect.center());
|
||||
if (screen == nullptr)
|
||||
{
|
||||
screen = QGuiApplication::screenAt(rect.topLeft());
|
||||
}
|
||||
if (screen == nullptr)
|
||||
{
|
||||
screen = QGuiApplication::primaryScreen();
|
||||
}
|
||||
return screen == nullptr ? QRect() : screen->availableGeometry();
|
||||
}
|
||||
|
||||
QPoint WorkspacePanel::constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const
|
||||
{
|
||||
if (!availableGeometry.isValid())
|
||||
{
|
||||
return preferredTopLeft;
|
||||
}
|
||||
|
||||
return QPoint(
|
||||
boundedCoordinate(preferredTopLeft.x(), availableGeometry.left() + ScreenMargin, availableGeometry.right() - width() - ScreenMargin),
|
||||
boundedCoordinate(preferredTopLeft.y(), availableGeometry.top() + ScreenMargin, availableGeometry.bottom() - height() - ScreenMargin));
|
||||
}
|
||||
|
||||
QPoint WorkspacePanel::smartTopLeftNear(const QRect &avoidRect) const
|
||||
{
|
||||
if (!avoidRect.isValid())
|
||||
{
|
||||
return pos();
|
||||
}
|
||||
|
||||
const QRect availableGeometry = availableGeometryForRect(avoidRect);
|
||||
const QVector<QPoint> preferredTopLefts = {
|
||||
QPoint(avoidRect.right() + 1 + PanelGap, avoidRect.top()),
|
||||
QPoint(avoidRect.left() - PanelGap - width(), avoidRect.top()),
|
||||
QPoint(avoidRect.center().x() - width() / 2, avoidRect.top() - PanelGap - height()),
|
||||
QPoint(avoidRect.center().x() - width() / 2, avoidRect.bottom() + 1 + PanelGap),
|
||||
};
|
||||
|
||||
QPoint bestTopLeft = constrainedTopLeft(preferredTopLefts.first(), availableGeometry);
|
||||
qint64 bestOverlap = std::numeric_limits<qint64>::max();
|
||||
qint64 bestDistance = std::numeric_limits<qint64>::max();
|
||||
int bestIndex = preferredTopLefts.size();
|
||||
for (int index = 0; index < preferredTopLefts.size(); ++index)
|
||||
{
|
||||
const QPoint candidateTopLeft = constrainedTopLeft(preferredTopLefts.at(index), availableGeometry);
|
||||
const QRect candidateRect(candidateTopLeft, size());
|
||||
const qint64 candidateOverlap = overlapArea(candidateRect, avoidRect);
|
||||
const qint64 candidateDistance = squaredDistance(candidateTopLeft, preferredTopLefts.at(index));
|
||||
if (candidateOverlap < bestOverlap
|
||||
|| (candidateOverlap == bestOverlap && candidateDistance < bestDistance)
|
||||
|| (candidateOverlap == bestOverlap && candidateDistance == bestDistance && index < bestIndex))
|
||||
{
|
||||
bestTopLeft = candidateTopLeft;
|
||||
bestOverlap = candidateOverlap;
|
||||
bestDistance = candidateDistance;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
return bestTopLeft;
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateSummary()
|
||||
{
|
||||
const QString workspaceText = m_snapshot.workspaceRoot.trimmed().isEmpty()
|
||||
? QStringLiteral("未选择工作区")
|
||||
: m_snapshot.workspaceRoot;
|
||||
const QString scanText = m_snapshot.lastScanAt.isValid()
|
||||
? m_snapshot.lastScanAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))
|
||||
: QStringLiteral("尚未扫描");
|
||||
m_workspaceLabel->setText(QStringLiteral("本地工作区 Agent"));
|
||||
m_scanLabel->setText(QStringLiteral("工作区:%1").arg(workspaceText));
|
||||
m_summaryLabel->setText(QStringLiteral("索引项:%1 最近扫描:%2")
|
||||
.arg(m_snapshot.indexedItemCount)
|
||||
.arg(scanText));
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateResults()
|
||||
{
|
||||
const int currentRow = m_resultList->currentRow();
|
||||
m_resultList->clear();
|
||||
if (m_snapshot.lastResults.isEmpty())
|
||||
{
|
||||
auto *item = new QListWidgetItem(QStringLiteral("暂无上一轮结果"));
|
||||
item->setFlags(Qt::NoItemFlags);
|
||||
m_resultList->addItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < m_snapshot.lastResults.size(); ++index)
|
||||
{
|
||||
m_resultList->addItem(QStringLiteral("%1. %2").arg(index + 1).arg(compactPath(m_snapshot.lastResults.at(index))));
|
||||
}
|
||||
if (currentRow >= 0 && currentRow < m_resultList->count())
|
||||
{
|
||||
m_resultList->setCurrentRow(currentRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_resultList->setCurrentRow(0);
|
||||
}
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateSelectedDetails()
|
||||
{
|
||||
const int index = selectedResultIndex();
|
||||
if (index < 0)
|
||||
{
|
||||
m_selectedDetailEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">请选择左侧结果。</div>"));
|
||||
return;
|
||||
}
|
||||
|
||||
const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index);
|
||||
const QString html = QStringLiteral(
|
||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
||||
"<div style=\"color:#cbd5e1;\">类型:%2<br>大小:%3 字节<br>修改时间:%4</div>")
|
||||
.arg(htmlLine(compactPath(file)))
|
||||
.arg(fileTypeText(file))
|
||||
.arg(file.sizeBytes)
|
||||
.arg(file.lastModified.isValid() ? file.lastModified.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("未知"));
|
||||
m_selectedDetailEdit->setHtml(html);
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateTextMatches()
|
||||
{
|
||||
if (m_snapshot.lastTextMatches.isEmpty())
|
||||
{
|
||||
m_textMatchesEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无文本搜索命中。</div>"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString html;
|
||||
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInDetails);
|
||||
for (int i = 0; i < matchCount; ++i)
|
||||
{
|
||||
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
|
||||
html += QStringLiteral("<div style=\"margin-bottom:9px;\"><b style=\"color:#bfdbfe;\">%1:%2</b><br><span style=\"color:#dbe4f0;\">%3</span></div>")
|
||||
.arg(htmlLine(match.relativePath))
|
||||
.arg(match.lineNumber)
|
||||
.arg(htmlLine(match.lineText));
|
||||
}
|
||||
if (m_snapshot.lastTextMatches.size() > matchCount)
|
||||
{
|
||||
html += QStringLiteral("<div style=\"color:#93a4bd;\">仅显示前 %1 条文本命中。</div>").arg(matchCount);
|
||||
}
|
||||
m_textMatchesEdit->setHtml(html);
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateRecentRead()
|
||||
{
|
||||
if (!m_snapshot.hasLastReadFile)
|
||||
{
|
||||
m_recentReadEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无最近读取文件。</div>"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString html = QStringLiteral(
|
||||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
||||
"<pre style=\"white-space:pre-wrap; color:#dbe4f0; font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px; margin:0;\">%2</pre>")
|
||||
.arg(htmlLine(m_snapshot.lastReadFile.relativePath))
|
||||
.arg(m_snapshot.lastReadPreview.toHtmlEscaped());
|
||||
m_recentReadEdit->setHtml(html);
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateDetails()
|
||||
{
|
||||
updateSelectedDetails();
|
||||
updateTextMatches();
|
||||
updateRecentRead();
|
||||
}
|
||||
|
||||
void WorkspacePanel::updateActionButtons()
|
||||
{
|
||||
const int index = selectedResultIndex();
|
||||
const bool hasSelection = index >= 0;
|
||||
m_openButton->setEnabled(hasSelection);
|
||||
m_revealButton->setEnabled(hasSelection);
|
||||
m_readButton->setEnabled(hasSelection && !m_snapshot.lastResults.at(index).isDirectory);
|
||||
}
|
||||
|
||||
int WorkspacePanel::selectedResultIndex() const
|
||||
{
|
||||
const int row = m_resultList->currentRow();
|
||||
if (row < 0 || row >= m_snapshot.lastResults.size())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "../workspace/WorkspaceTypes.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QMouseEvent>
|
||||
#include <QPoint>
|
||||
#include <QRect>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QLabel;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QTextEdit;
|
||||
|
||||
class WorkspacePanel : public QDialog
|
||||
{
|
||||
public:
|
||||
using ResultActionCallback = std::function<void(int)>;
|
||||
|
||||
explicit WorkspacePanel(QWidget *parent = nullptr);
|
||||
|
||||
void setSnapshot(const WorkspacePanelSnapshot &snapshot);
|
||||
void showNear(const QRect &avoidRect);
|
||||
|
||||
void setReadCallback(ResultActionCallback callback);
|
||||
void setOpenCallback(ResultActionCallback callback);
|
||||
void setRevealCallback(ResultActionCallback callback);
|
||||
|
||||
protected:
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
|
||||
private:
|
||||
QRect availableGeometryForRect(const QRect &rect) const;
|
||||
QPoint constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const;
|
||||
QPoint smartTopLeftNear(const QRect &avoidRect) const;
|
||||
void updateSummary();
|
||||
void updateResults();
|
||||
void updateSelectedDetails();
|
||||
void updateTextMatches();
|
||||
void updateRecentRead();
|
||||
void updateDetails();
|
||||
void updateActionButtons();
|
||||
int selectedResultIndex() const;
|
||||
|
||||
QLabel *m_workspaceLabel;
|
||||
QLabel *m_scanLabel;
|
||||
QLabel *m_summaryLabel;
|
||||
QListWidget *m_resultList;
|
||||
QTextEdit *m_selectedDetailEdit;
|
||||
QTextEdit *m_textMatchesEdit;
|
||||
QTextEdit *m_recentReadEdit;
|
||||
QPushButton *m_readButton;
|
||||
QPushButton *m_openButton;
|
||||
QPushButton *m_revealButton;
|
||||
QPushButton *m_closeButton;
|
||||
WorkspacePanelSnapshot m_snapshot;
|
||||
ResultActionCallback m_readCallback;
|
||||
ResultActionCallback m_openCallback;
|
||||
ResultActionCallback m_revealCallback;
|
||||
QPoint m_dragStartPosition;
|
||||
QPoint m_dragWindowPosition;
|
||||
bool m_dragging = false;
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
#include "WorkspaceAccessPolicy.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
QStringList systemPathPrefixes()
|
||||
{
|
||||
QStringList prefixes = {
|
||||
QStringLiteral("C:/WINDOWS"),
|
||||
QStringLiteral("C:/PROGRAM FILES"),
|
||||
QStringLiteral("C:/PROGRAM FILES (X86)"),
|
||||
QStringLiteral("C:/PROGRAMDATA"),
|
||||
QStringLiteral("C:/SYSTEM VOLUME INFORMATION"),
|
||||
QStringLiteral("C:/$RECYCLE.BIN"),
|
||||
};
|
||||
|
||||
const auto addEnvironmentPath = [&prefixes](const char *name) {
|
||||
const QString value = qEnvironmentVariable(name).trimmed();
|
||||
if (!value.isEmpty())
|
||||
{
|
||||
QString cleaned = QDir::cleanPath(QFileInfo(value).absoluteFilePath());
|
||||
cleaned.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||
prefixes.append(cleaned.toUpper());
|
||||
}
|
||||
};
|
||||
|
||||
addEnvironmentPath("WINDIR");
|
||||
addEnvironmentPath("SystemRoot");
|
||||
addEnvironmentPath("ProgramFiles");
|
||||
addEnvironmentPath("ProgramFiles(x86)");
|
||||
addEnvironmentPath("ProgramData");
|
||||
return prefixes;
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceAccessPolicy::WorkspaceAccessPolicy(const QString &workspaceRoot)
|
||||
: m_workspaceRoot(cleanedAbsolutePath(workspaceRoot))
|
||||
{
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::validateWorkspaceRoot(const QString &path, QString *errorMessage)
|
||||
{
|
||||
const QFileInfo info(path);
|
||||
if (!info.exists() || !info.isDir())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("请选择一个存在的工作区目录。"));
|
||||
}
|
||||
if (!info.isReadable())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("工作区目录不可读取。"));
|
||||
}
|
||||
|
||||
const QString absolutePath = info.absoluteFilePath();
|
||||
if (pathHasSymlinkSegment(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("不允许选择符号链接或符号链接目录作为工作区。"));
|
||||
}
|
||||
|
||||
const WorkspaceAccessPolicy policy(absolutePath);
|
||||
if (policy.isSystemPath(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("不允许把 Windows 系统目录作为工作区。"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString WorkspaceAccessPolicy::workspaceRoot() const
|
||||
{
|
||||
return m_workspaceRoot;
|
||||
}
|
||||
|
||||
QString WorkspaceAccessPolicy::normalizePath(const QString &path) const
|
||||
{
|
||||
return cleanedAbsolutePath(path);
|
||||
}
|
||||
|
||||
QString WorkspaceAccessPolicy::toAbsolutePath(const QString &relativeOrAbsolutePath) const
|
||||
{
|
||||
const QFileInfo info(relativeOrAbsolutePath);
|
||||
if (info.isAbsolute())
|
||||
{
|
||||
return cleanedAbsolutePath(relativeOrAbsolutePath);
|
||||
}
|
||||
return cleanedAbsolutePath(QDir(m_workspaceRoot).filePath(relativeOrAbsolutePath));
|
||||
}
|
||||
|
||||
QString WorkspaceAccessPolicy::toRelativePath(const QString &absolutePath) const
|
||||
{
|
||||
return QDir(m_workspaceRoot).relativeFilePath(cleanedAbsolutePath(absolutePath));
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::isInsideWorkspace(const QString &path, QString *errorMessage) const
|
||||
{
|
||||
if (m_workspaceRoot.trimmed().isEmpty())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("尚未选择工作区。"));
|
||||
}
|
||||
|
||||
const QString root = comparablePath(m_workspaceRoot);
|
||||
const QString checkedPath = comparablePath(toAbsolutePath(path));
|
||||
if (checkedPath == root || checkedPath.startsWith(root + QLatin1Char('/')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return setError(errorMessage, QStringLiteral("路径不在当前工作区内。"));
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::hasSymlinkSegment(const QString &path, QString *errorMessage) const
|
||||
{
|
||||
if (pathHasSymlinkSegment(path))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("不允许访问符号链接或符号链接目录内的路径。"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::isSystemPath(const QString &path) const
|
||||
{
|
||||
const QString checkedPath = cleanedAbsolutePath(path).toUpper();
|
||||
for (const QString &prefix : systemPathPrefixes())
|
||||
{
|
||||
if (checkedPath == prefix || checkedPath.startsWith(prefix + QLatin1Char('/')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::isDangerousSuffix(const QString &path) const
|
||||
{
|
||||
static const QStringList dangerous = {
|
||||
QStringLiteral("exe"),
|
||||
QStringLiteral("bat"),
|
||||
QStringLiteral("cmd"),
|
||||
QStringLiteral("ps1"),
|
||||
QStringLiteral("vbs"),
|
||||
QStringLiteral("msi"),
|
||||
QStringLiteral("scr"),
|
||||
QStringLiteral("reg"),
|
||||
QStringLiteral("com"),
|
||||
QStringLiteral("pif"),
|
||||
QStringLiteral("lnk"),
|
||||
};
|
||||
return dangerous.contains(QFileInfo(path).suffix().trimmed().toLower());
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::isSensitiveFile(const QString &path) const
|
||||
{
|
||||
const QFileInfo info(path);
|
||||
const QString fileName = info.fileName().toLower();
|
||||
const QString suffix = info.suffix().toLower();
|
||||
if (fileName == QStringLiteral(".env")
|
||||
|| fileName == QStringLiteral("id_rsa")
|
||||
|| fileName == QStringLiteral("id_ed25519")
|
||||
|| fileName == QStringLiteral("credentials.json")
|
||||
|| fileName == QStringLiteral("secrets.json")
|
||||
|| fileName == QStringLiteral("token.json")
|
||||
|| suffix == QStringLiteral("pem")
|
||||
|| suffix == QStringLiteral("key")
|
||||
|| suffix == QStringLiteral("pfx")
|
||||
|| suffix == QStringLiteral("p12"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
static const QStringList sensitiveKeywords = {
|
||||
QStringLiteral("secret"),
|
||||
QStringLiteral("token"),
|
||||
QStringLiteral("password"),
|
||||
QStringLiteral("credential"),
|
||||
QStringLiteral("private"),
|
||||
QStringLiteral("apikey"),
|
||||
QStringLiteral("api_key"),
|
||||
};
|
||||
for (const QString &keyword : sensitiveKeywords)
|
||||
{
|
||||
if (fileName.contains(keyword))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::isSupportedTextFile(const QString &path) const
|
||||
{
|
||||
const QFileInfo info(path);
|
||||
const QString fileName = info.fileName().toLower();
|
||||
const QString suffix = info.suffix().toLower();
|
||||
if (fileName == QStringLiteral("cmakelists.txt"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
static const QStringList supported = {
|
||||
QStringLiteral("cpp"),
|
||||
QStringLiteral("h"),
|
||||
QStringLiteral("hpp"),
|
||||
QStringLiteral("c"),
|
||||
QStringLiteral("cc"),
|
||||
QStringLiteral("py"),
|
||||
QStringLiteral("js"),
|
||||
QStringLiteral("ts"),
|
||||
QStringLiteral("json"),
|
||||
QStringLiteral("md"),
|
||||
QStringLiteral("markdown"),
|
||||
QStringLiteral("txt"),
|
||||
QStringLiteral("log"),
|
||||
QStringLiteral("ini"),
|
||||
QStringLiteral("xml"),
|
||||
QStringLiteral("yaml"),
|
||||
QStringLiteral("yml"),
|
||||
QStringLiteral("cmake"),
|
||||
QStringLiteral("pro"),
|
||||
QStringLiteral("css"),
|
||||
QStringLiteral("html"),
|
||||
QStringLiteral("qss"),
|
||||
};
|
||||
return supported.contains(suffix);
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::canReadFile(const QString &path, QString *errorMessage) const
|
||||
{
|
||||
const QString absolutePath = toAbsolutePath(path);
|
||||
if (!isInsideWorkspace(absolutePath, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QFileInfo info(absolutePath);
|
||||
if (!info.exists() || !info.isFile())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("请选择工作区内存在的文件。"));
|
||||
}
|
||||
if (!info.isReadable())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("文件不可读取。"));
|
||||
}
|
||||
if (hasSymlinkSegment(absolutePath, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isSystemPath(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("不允许读取系统目录内的文件。"));
|
||||
}
|
||||
if (isSensitiveFile(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("该文件可能包含敏感信息,已拒绝读取。"));
|
||||
}
|
||||
if (!isSupportedTextFile(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("只允许读取工作区内常见文本文件。"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::canOpenFile(const QString &path, QString *errorMessage) const
|
||||
{
|
||||
const QString absolutePath = toAbsolutePath(path);
|
||||
if (!isInsideWorkspace(absolutePath, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QFileInfo info(absolutePath);
|
||||
if (!info.exists() || !info.isFile())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("请选择工作区内存在的普通文件。"));
|
||||
}
|
||||
if (!info.isReadable())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("文件不可读取。"));
|
||||
}
|
||||
if (hasSymlinkSegment(absolutePath, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isSystemPath(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("不允许打开系统目录内的文件。"));
|
||||
}
|
||||
if (isSensitiveFile(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("该文件可能包含敏感信息,已拒绝打开。"));
|
||||
}
|
||||
if (isDangerousSuffix(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("该文件类型可能执行程序或脚本,已拒绝打开。"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::canReadDirectory(const QString &path, QString *errorMessage) const
|
||||
{
|
||||
const QString absolutePath = toAbsolutePath(path);
|
||||
if (!isInsideWorkspace(absolutePath, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QFileInfo info(absolutePath);
|
||||
if (!info.exists() || !info.isDir())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("请选择工作区内存在的目录。"));
|
||||
}
|
||||
if (!info.isReadable())
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("目录不可读取。"));
|
||||
}
|
||||
if (hasSymlinkSegment(absolutePath, errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isSystemPath(absolutePath))
|
||||
{
|
||||
return setError(errorMessage, QStringLiteral("不允许读取系统目录。"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::setError(QString *errorMessage, const QString &message)
|
||||
{
|
||||
if (errorMessage != nullptr)
|
||||
{
|
||||
*errorMessage = message;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QString WorkspaceAccessPolicy::cleanedAbsolutePath(const QString &path)
|
||||
{
|
||||
QString cleaned = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
||||
cleaned.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
QString WorkspaceAccessPolicy::comparablePath(const QString &path)
|
||||
{
|
||||
return cleanedAbsolutePath(path).toUpper();
|
||||
}
|
||||
|
||||
bool WorkspaceAccessPolicy::pathHasSymlinkSegment(const QString &path)
|
||||
{
|
||||
QFileInfo info(path);
|
||||
if (info.isSymLink())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
QDir directory(info.isDir() ? info.absoluteFilePath() : info.absolutePath());
|
||||
while (directory.exists())
|
||||
{
|
||||
const QFileInfo directoryInfo(directory.absolutePath());
|
||||
if (directoryInfo.isSymLink())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!directory.cdUp())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
class WorkspaceAccessPolicy
|
||||
{
|
||||
public:
|
||||
WorkspaceAccessPolicy() = default;
|
||||
explicit WorkspaceAccessPolicy(const QString &workspaceRoot);
|
||||
|
||||
static bool validateWorkspaceRoot(const QString &path, QString *errorMessage = nullptr);
|
||||
|
||||
QString workspaceRoot() const;
|
||||
QString normalizePath(const QString &path) const;
|
||||
QString toAbsolutePath(const QString &relativeOrAbsolutePath) const;
|
||||
QString toRelativePath(const QString &absolutePath) const;
|
||||
|
||||
bool isInsideWorkspace(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool hasSymlinkSegment(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool isSystemPath(const QString &path) const;
|
||||
bool isDangerousSuffix(const QString &path) const;
|
||||
bool isSensitiveFile(const QString &path) const;
|
||||
bool isSupportedTextFile(const QString &path) const;
|
||||
|
||||
bool canReadFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canReadDirectory(const QString &path, QString *errorMessage = nullptr) const;
|
||||
bool canOpenFile(const QString &path, QString *errorMessage = nullptr) const;
|
||||
|
||||
private:
|
||||
static bool setError(QString *errorMessage, const QString &message);
|
||||
static QString cleanedAbsolutePath(const QString &path);
|
||||
static QString comparablePath(const QString &path);
|
||||
static bool pathHasSymlinkSegment(const QString &path);
|
||||
|
||||
QString m_workspaceRoot;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
class WorkspaceAgent
|
||||
{
|
||||
public:
|
||||
static QString systemPrompt();
|
||||
static QString projectAnalysisPrompt(const QString &workspaceRoot, const QString &structureSummary);
|
||||
static QString fileSummaryPrompt(const QString &relativePath, const QString &content);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "WorkspaceScanner.h"
|
||||
#include "WorkspaceSession.h"
|
||||
#include "WorkspaceTypes.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
class WorkspaceController
|
||||
{
|
||||
public:
|
||||
WorkspaceController();
|
||||
|
||||
bool openWorkspace(const QString &rootPath, QString *errorMessage = nullptr);
|
||||
void closeWorkspace();
|
||||
bool hasWorkspace() const;
|
||||
QString workspaceRoot() const;
|
||||
|
||||
WorkspaceToolResult scanWorkspace();
|
||||
WorkspaceToolResult handleMessage(const QString &message);
|
||||
WorkspaceToolResult executeActionPlan(const WorkspaceActionPlan &plan);
|
||||
WorkspaceToolResult readLastResult(int zeroBasedIndex);
|
||||
WorkspaceToolResult openLastResult(int zeroBasedIndex);
|
||||
WorkspaceToolResult revealLastResult(int zeroBasedIndex);
|
||||
WorkspacePanelSnapshot snapshot() const;
|
||||
QVector<WorkspaceFileInfo> lastResults() const;
|
||||
|
||||
private:
|
||||
WorkspaceToolResult currentWorkspaceInfo() const;
|
||||
WorkspaceToolResult listWorkspaceRoot() const;
|
||||
WorkspaceToolResult searchFiles(const QString &keyword);
|
||||
WorkspaceToolResult searchText(const QString &keyword);
|
||||
WorkspaceToolResult readFileByQuery(const QString &query, bool requestSummary);
|
||||
WorkspaceToolResult openItemByQuery(const QString &query, bool revealInFileManager);
|
||||
WorkspaceToolResult itemFromLastResults(int zeroBasedIndex, WorkspaceFileInfo *file) const;
|
||||
WorkspaceToolResult projectSummary(bool requestAiSummary) const;
|
||||
|
||||
QString extractSearchKeyword(const QString &message) const;
|
||||
QString extractTextSearchKeyword(const QString &message) const;
|
||||
QString extractReadTarget(const QString &message) const;
|
||||
QString extractOpenTarget(const QString &message) const;
|
||||
int ordinalIndex(const QString &message) const;
|
||||
bool isTextSearchMessage(const QString &message) const;
|
||||
bool isProjectSummaryMessage(const QString &message) const;
|
||||
bool isFileSummaryMessage(const QString &message) const;
|
||||
bool isOpenMessage(const QString &message) const;
|
||||
bool isRevealMessage(const QString &message) const;
|
||||
|
||||
WorkspaceSession m_session;
|
||||
WorkspaceScanner m_scanner;
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
#include "WorkspaceIndex.h"
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
void WorkspaceIndex::clear()
|
||||
{
|
||||
m_files.clear();
|
||||
}
|
||||
|
||||
void WorkspaceIndex::setFiles(const QVector<WorkspaceFileInfo> &files)
|
||||
{
|
||||
m_files = files;
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> WorkspaceIndex::allFiles() const
|
||||
{
|
||||
return m_files;
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> WorkspaceIndex::findByName(const QString &keyword, int maxResults) const
|
||||
{
|
||||
const QString normalizedKeyword = keyword.trimmed().toLower();
|
||||
if (normalizedKeyword.isEmpty() || maxResults <= 0)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> matches;
|
||||
for (const WorkspaceFileInfo &file : m_files)
|
||||
{
|
||||
const QString fileName = file.fileName.toLower();
|
||||
const QString relativePath = file.relativePath.toLower();
|
||||
const QString suffixToken = QStringLiteral(".") + file.suffix.toLower();
|
||||
if (fileName.contains(normalizedKeyword)
|
||||
|| relativePath.contains(normalizedKeyword)
|
||||
|| (!file.suffix.isEmpty() && normalizedKeyword == suffixToken))
|
||||
{
|
||||
matches.append(file);
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(matches.begin(), matches.end(), [&normalizedKeyword](const WorkspaceFileInfo &left, const WorkspaceFileInfo &right) {
|
||||
const bool leftExact = left.fileName.compare(normalizedKeyword, Qt::CaseInsensitive) == 0;
|
||||
const bool rightExact = right.fileName.compare(normalizedKeyword, Qt::CaseInsensitive) == 0;
|
||||
if (leftExact != rightExact)
|
||||
{
|
||||
return leftExact;
|
||||
}
|
||||
|
||||
const bool leftFile = !left.isDirectory;
|
||||
const bool rightFile = !right.isDirectory;
|
||||
if (leftFile != rightFile)
|
||||
{
|
||||
return leftFile;
|
||||
}
|
||||
|
||||
return left.lastModified > right.lastModified;
|
||||
});
|
||||
|
||||
if (matches.size() > maxResults)
|
||||
{
|
||||
matches.resize(maxResults);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> WorkspaceIndex::findBySuffix(const QStringList &suffixes, int maxResults) const
|
||||
{
|
||||
if (suffixes.isEmpty() || maxResults <= 0)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
QStringList normalizedSuffixes;
|
||||
for (QString suffix : suffixes)
|
||||
{
|
||||
suffix = suffix.trimmed().toLower();
|
||||
suffix.remove(QLatin1Char('.'));
|
||||
if (!suffix.isEmpty())
|
||||
{
|
||||
normalizedSuffixes.append(suffix);
|
||||
}
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> matches;
|
||||
for (const WorkspaceFileInfo &file : m_files)
|
||||
{
|
||||
if (!file.isDirectory && normalizedSuffixes.contains(file.suffix.toLower()))
|
||||
{
|
||||
matches.append(file);
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(matches.begin(), matches.end(), [](const WorkspaceFileInfo &left, const WorkspaceFileInfo &right) {
|
||||
return left.lastModified > right.lastModified;
|
||||
});
|
||||
|
||||
if (matches.size() > maxResults)
|
||||
{
|
||||
matches.resize(maxResults);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> WorkspaceIndex::recentFiles(int maxResults) const
|
||||
{
|
||||
if (maxResults <= 0)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> files;
|
||||
for (const WorkspaceFileInfo &file : m_files)
|
||||
{
|
||||
if (!file.isDirectory)
|
||||
{
|
||||
files.append(file);
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(files.begin(), files.end(), [](const WorkspaceFileInfo &left, const WorkspaceFileInfo &right) {
|
||||
return left.lastModified > right.lastModified;
|
||||
});
|
||||
|
||||
if (files.size() > maxResults)
|
||||
{
|
||||
files.resize(maxResults);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
int WorkspaceIndex::size() const
|
||||
{
|
||||
return m_files.size();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "WorkspaceTypes.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
class WorkspaceIndex
|
||||
{
|
||||
public:
|
||||
void clear();
|
||||
void setFiles(const QVector<WorkspaceFileInfo> &files);
|
||||
|
||||
QVector<WorkspaceFileInfo> allFiles() const;
|
||||
QVector<WorkspaceFileInfo> findByName(const QString &keyword, int maxResults) const;
|
||||
QVector<WorkspaceFileInfo> findBySuffix(const QStringList &suffixes, int maxResults) const;
|
||||
QVector<WorkspaceFileInfo> recentFiles(int maxResults) const;
|
||||
int size() const;
|
||||
|
||||
private:
|
||||
QVector<WorkspaceFileInfo> m_files;
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "WorkspaceScanner.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
namespace
|
||||
{
|
||||
struct PendingDirectory
|
||||
{
|
||||
QString path;
|
||||
int depth = 0;
|
||||
};
|
||||
}
|
||||
|
||||
WorkspaceScanner::WorkspaceScanner(int maxDepth, int maxEntries)
|
||||
: m_maxDepth(maxDepth)
|
||||
, m_maxEntries(maxEntries)
|
||||
{
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> WorkspaceScanner::scan(const QString &workspaceRoot, QString *errorMessage) const
|
||||
{
|
||||
if (!WorkspaceAccessPolicy::validateWorkspaceRoot(workspaceRoot, errorMessage))
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
const WorkspaceAccessPolicy policy(workspaceRoot);
|
||||
QVector<WorkspaceFileInfo> files;
|
||||
QVector<PendingDirectory> pending;
|
||||
pending.append({policy.workspaceRoot(), 0});
|
||||
|
||||
while (!pending.isEmpty() && files.size() < m_maxEntries)
|
||||
{
|
||||
const PendingDirectory current = pending.takeLast();
|
||||
QString directoryError;
|
||||
if (!policy.canReadDirectory(current.path, &directoryError))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QFileInfoList entries = QDir(current.path).entryInfoList(
|
||||
QDir::NoDotAndDotDot | QDir::AllEntries | QDir::Hidden,
|
||||
QDir::DirsFirst | QDir::Name);
|
||||
|
||||
for (const QFileInfo &entry : entries)
|
||||
{
|
||||
if (files.size() >= m_maxEntries)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (entry.isHidden())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString absolutePath = entry.absoluteFilePath();
|
||||
if (entry.isDir())
|
||||
{
|
||||
if (shouldSkipDirectory(policy, absolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
files.append(fileInfoForPath(policy, absolutePath));
|
||||
if (current.depth + 1 <= m_maxDepth)
|
||||
{
|
||||
pending.append({absolutePath, current.depth + 1});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile())
|
||||
{
|
||||
if (policy.hasSymlinkSegment(absolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
files.append(fileInfoForPath(policy, absolutePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
bool WorkspaceScanner::shouldSkipDirectory(const WorkspaceAccessPolicy &policy, const QString &path) const
|
||||
{
|
||||
const QFileInfo info(path);
|
||||
const QString name = info.fileName().toLower();
|
||||
static const QStringList skipped = {
|
||||
QStringLiteral(".git"),
|
||||
QStringLiteral(".vs"),
|
||||
QStringLiteral(".idea"),
|
||||
QStringLiteral("build"),
|
||||
QStringLiteral("cmake-build-debug"),
|
||||
QStringLiteral("cmake-build-release"),
|
||||
QStringLiteral("node_modules"),
|
||||
QStringLiteral("dist"),
|
||||
QStringLiteral("release"),
|
||||
QStringLiteral("release_packages"),
|
||||
QStringLiteral("logs"),
|
||||
};
|
||||
return skipped.contains(name)
|
||||
|| policy.isSystemPath(path)
|
||||
|| policy.hasSymlinkSegment(path);
|
||||
}
|
||||
|
||||
WorkspaceFileInfo WorkspaceScanner::fileInfoForPath(const WorkspaceAccessPolicy &policy, const QString &path) const
|
||||
{
|
||||
const QFileInfo info(path);
|
||||
WorkspaceFileInfo file;
|
||||
file.absolutePath = policy.normalizePath(path);
|
||||
file.relativePath = policy.toRelativePath(file.absolutePath);
|
||||
file.fileName = info.fileName();
|
||||
file.suffix = info.suffix();
|
||||
file.sizeBytes = info.size();
|
||||
file.lastModified = info.lastModified();
|
||||
file.isDirectory = info.isDir();
|
||||
file.isTextFile = !file.isDirectory && policy.isSupportedTextFile(file.absolutePath) && !policy.isSensitiveFile(file.absolutePath);
|
||||
file.isHidden = info.isHidden();
|
||||
return file;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "WorkspaceAccessPolicy.h"
|
||||
#include "WorkspaceTypes.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
class WorkspaceScanner
|
||||
{
|
||||
public:
|
||||
explicit WorkspaceScanner(int maxDepth = 8, int maxEntries = 5000);
|
||||
|
||||
QVector<WorkspaceFileInfo> scan(const QString &workspaceRoot, QString *errorMessage = nullptr) const;
|
||||
|
||||
private:
|
||||
bool shouldSkipDirectory(const WorkspaceAccessPolicy &policy, const QString &path) const;
|
||||
WorkspaceFileInfo fileInfoForPath(const WorkspaceAccessPolicy &policy, const QString &path) const;
|
||||
|
||||
int m_maxDepth = 8;
|
||||
int m_maxEntries = 5000;
|
||||
};
|
||||
@@ -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<WorkspaceFileInfo> &files)
|
||||
{
|
||||
m_lastResults = files;
|
||||
}
|
||||
|
||||
QVector<WorkspaceFileInfo> WorkspaceSession::lastResults() const
|
||||
{
|
||||
return m_lastResults;
|
||||
}
|
||||
|
||||
void WorkspaceSession::setLastTextMatches(const QVector<WorkspaceTextMatch> &matches)
|
||||
{
|
||||
m_lastTextMatches = matches;
|
||||
}
|
||||
|
||||
QVector<WorkspaceTextMatch> WorkspaceSession::lastTextMatches() const
|
||||
{
|
||||
return m_lastTextMatches;
|
||||
}
|
||||
|
||||
void WorkspaceSession::setLastReadFile(const WorkspaceFileInfo &file, const QString &content)
|
||||
{
|
||||
m_lastReadFile = file;
|
||||
m_lastReadContent = content;
|
||||
m_hasLastReadFile = true;
|
||||
}
|
||||
|
||||
WorkspaceFileInfo WorkspaceSession::lastReadFile() const
|
||||
{
|
||||
return m_lastReadFile;
|
||||
}
|
||||
|
||||
QString WorkspaceSession::lastReadContent() const
|
||||
{
|
||||
return m_lastReadContent;
|
||||
}
|
||||
|
||||
bool WorkspaceSession::hasLastReadFile() const
|
||||
{
|
||||
return m_hasLastReadFile;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include "WorkspaceIndex.h"
|
||||
#include "WorkspaceTypes.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
class WorkspaceSession
|
||||
{
|
||||
public:
|
||||
bool hasWorkspace() const;
|
||||
QString workspaceRoot() const;
|
||||
void setWorkspaceRoot(const QString &workspaceRoot);
|
||||
void clear();
|
||||
|
||||
WorkspaceIndex &index();
|
||||
const WorkspaceIndex &index() const;
|
||||
|
||||
void setLastScanAt(const QDateTime &dateTime);
|
||||
QDateTime lastScanAt() const;
|
||||
|
||||
void setLastResults(const QVector<WorkspaceFileInfo> &files);
|
||||
QVector<WorkspaceFileInfo> lastResults() const;
|
||||
|
||||
void setLastTextMatches(const QVector<WorkspaceTextMatch> &matches);
|
||||
QVector<WorkspaceTextMatch> lastTextMatches() const;
|
||||
|
||||
void setLastReadFile(const WorkspaceFileInfo &file, const QString &content);
|
||||
WorkspaceFileInfo lastReadFile() const;
|
||||
QString lastReadContent() const;
|
||||
bool hasLastReadFile() const;
|
||||
|
||||
private:
|
||||
QString m_workspaceRoot;
|
||||
WorkspaceIndex m_index;
|
||||
QDateTime m_lastScanAt;
|
||||
QVector<WorkspaceFileInfo> m_lastResults;
|
||||
QVector<WorkspaceTextMatch> m_lastTextMatches;
|
||||
WorkspaceFileInfo m_lastReadFile;
|
||||
QString m_lastReadContent;
|
||||
bool m_hasLastReadFile = false;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "WorkspaceTypes.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
class WorkspaceTool
|
||||
{
|
||||
public:
|
||||
virtual ~WorkspaceTool() = default;
|
||||
|
||||
virtual QString name() const = 0;
|
||||
virtual QString displayName() const = 0;
|
||||
virtual WorkspaceToolRisk risk() const = 0;
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
enum class WorkspaceActionType
|
||||
{
|
||||
Unknown,
|
||||
ListFiles,
|
||||
SearchFiles,
|
||||
SearchText,
|
||||
ReadFile,
|
||||
ProjectSummary,
|
||||
FileSummary,
|
||||
OpenFile,
|
||||
OpenDirectory,
|
||||
RevealInFileManager,
|
||||
};
|
||||
|
||||
enum class WorkspaceToolRisk
|
||||
{
|
||||
ReadOnly,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Dangerous,
|
||||
};
|
||||
|
||||
struct WorkspaceFileInfo
|
||||
{
|
||||
QString relativePath;
|
||||
QString absolutePath;
|
||||
QString fileName;
|
||||
QString suffix;
|
||||
qint64 sizeBytes = 0;
|
||||
QDateTime lastModified;
|
||||
bool isDirectory = false;
|
||||
bool isTextFile = false;
|
||||
bool isHidden = false;
|
||||
};
|
||||
|
||||
struct WorkspaceTextMatch
|
||||
{
|
||||
QString relativePath;
|
||||
QString absolutePath;
|
||||
int lineNumber = 0;
|
||||
QString lineText;
|
||||
};
|
||||
|
||||
struct WorkspaceActionPlan
|
||||
{
|
||||
QString id;
|
||||
WorkspaceActionType type = WorkspaceActionType::Unknown;
|
||||
WorkspaceToolRisk risk = WorkspaceToolRisk::ReadOnly;
|
||||
QString title;
|
||||
QString description;
|
||||
QString sourcePath;
|
||||
QString targetPath;
|
||||
QString relativeSourcePath;
|
||||
QString relativeTargetPath;
|
||||
QString content;
|
||||
QString searchKeyword;
|
||||
QStringList warnings;
|
||||
bool requiresConfirmation = false;
|
||||
bool allowOverwrite = false;
|
||||
bool confirmed = false;
|
||||
};
|
||||
|
||||
struct WorkspaceToolResult
|
||||
{
|
||||
bool success = false;
|
||||
QString message;
|
||||
QString errorMessage;
|
||||
QString outputText;
|
||||
QString targetPath;
|
||||
QVector<WorkspaceFileInfo> files;
|
||||
QVector<WorkspaceTextMatch> textMatches;
|
||||
bool shouldRequestAiSummary = false;
|
||||
QString aiPrompt;
|
||||
QString aiFallbackText;
|
||||
bool hasActionPlan = false;
|
||||
WorkspaceActionPlan actionPlan;
|
||||
};
|
||||
|
||||
struct WorkspacePanelSnapshot
|
||||
{
|
||||
QString workspaceRoot;
|
||||
QDateTime lastScanAt;
|
||||
int indexedItemCount = 0;
|
||||
QVector<WorkspaceFileInfo> lastResults;
|
||||
QVector<WorkspaceTextMatch> lastTextMatches;
|
||||
bool hasLastReadFile = false;
|
||||
WorkspaceFileInfo lastReadFile;
|
||||
QString lastReadPreview;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "ListFilesTool.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int MaxListEntries = 200;
|
||||
}
|
||||
|
||||
ListFilesTool::ListFilesTool(const WorkspaceAccessPolicy &policy)
|
||||
: m_policy(policy)
|
||||
{
|
||||
}
|
||||
|
||||
WorkspaceToolResult ListFilesTool::run(const QString &directoryPath) const
|
||||
{
|
||||
const QString targetDirectory = directoryPath.trimmed().isEmpty()
|
||||
? m_policy.workspaceRoot()
|
||||
: m_policy.toAbsolutePath(directoryPath);
|
||||
|
||||
WorkspaceToolResult result;
|
||||
QString errorMessage;
|
||||
if (!m_policy.canReadDirectory(targetDirectory, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
const QFileInfoList entries = QDir(targetDirectory).entryInfoList(
|
||||
QDir::NoDotAndDotDot | QDir::AllEntries,
|
||||
QDir::DirsFirst | QDir::Name);
|
||||
|
||||
QStringList lines;
|
||||
lines.append(QStringLiteral("目录:%1").arg(m_policy.toRelativePath(targetDirectory)));
|
||||
const int count = qMin(entries.size(), MaxListEntries);
|
||||
for (int index = 0; index < count; ++index)
|
||||
{
|
||||
const QFileInfo &entry = entries.at(index);
|
||||
const QString label = entry.isDir() ? QStringLiteral("[目录]") : QStringLiteral("[文件]");
|
||||
lines.append(QStringLiteral("%1 %2").arg(label, entry.fileName()));
|
||||
}
|
||||
if (entries.size() > MaxListEntries)
|
||||
{
|
||||
lines.append(QStringLiteral("[仅显示前 %1 项,共 %2 项]").arg(MaxListEntries).arg(entries.size()));
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = QStringLiteral("已列出工作区目录。");
|
||||
result.outputText = lines.join(QLatin1Char('\n'));
|
||||
return result;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "OpenDirectoryTool.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QFileInfo>
|
||||
#include <QUrl>
|
||||
|
||||
OpenDirectoryTool::OpenDirectoryTool(const WorkspaceAccessPolicy &policy)
|
||||
: m_policy(policy)
|
||||
{
|
||||
}
|
||||
|
||||
WorkspaceToolResult OpenDirectoryTool::run(const WorkspaceFileInfo &directory) const
|
||||
{
|
||||
return run(directory.absolutePath);
|
||||
}
|
||||
|
||||
WorkspaceToolResult OpenDirectoryTool::run(const QString &directoryPath) const
|
||||
{
|
||||
WorkspaceToolResult result;
|
||||
const QString absolutePath = m_policy.toAbsolutePath(directoryPath);
|
||||
QString errorMessage;
|
||||
if (!m_policy.canReadDirectory(absolutePath, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
const WorkspaceFileInfo directory = directoryInfoForPath(absolutePath);
|
||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory.absolutePath)))
|
||||
{
|
||||
result.errorMessage = QStringLiteral("打开文件夹失败。");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = QStringLiteral("已打开文件夹:%1").arg(directory.relativePath);
|
||||
result.targetPath = directory.absolutePath;
|
||||
result.files.append(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
WorkspaceFileInfo OpenDirectoryTool::directoryInfoForPath(const QString &directoryPath) const
|
||||
{
|
||||
const QFileInfo info(directoryPath);
|
||||
WorkspaceFileInfo directory;
|
||||
directory.absolutePath = m_policy.normalizePath(directoryPath);
|
||||
directory.relativePath = m_policy.toRelativePath(directory.absolutePath);
|
||||
directory.fileName = info.fileName();
|
||||
directory.suffix = info.suffix();
|
||||
directory.sizeBytes = info.size();
|
||||
directory.lastModified = info.lastModified();
|
||||
directory.isDirectory = info.isDir();
|
||||
directory.isTextFile = false;
|
||||
directory.isHidden = info.isHidden();
|
||||
return directory;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "OpenFileTool.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QFileInfo>
|
||||
#include <QUrl>
|
||||
|
||||
OpenFileTool::OpenFileTool(const WorkspaceAccessPolicy &policy)
|
||||
: m_policy(policy)
|
||||
{
|
||||
}
|
||||
|
||||
WorkspaceToolResult OpenFileTool::prepare(const WorkspaceFileInfo &file) const
|
||||
{
|
||||
WorkspaceToolResult result;
|
||||
QString errorMessage;
|
||||
if (!m_policy.canOpenFile(file.absolutePath, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
const WorkspaceFileInfo normalizedFile = fileInfoForPath(file.absolutePath);
|
||||
WorkspaceActionPlan plan;
|
||||
plan.type = WorkspaceActionType::OpenFile;
|
||||
plan.risk = WorkspaceToolRisk::Low;
|
||||
plan.title = QStringLiteral("打开工作区文件");
|
||||
plan.description = QStringLiteral("将用系统默认程序打开工作区文件:\n%1").arg(normalizedFile.relativePath);
|
||||
plan.sourcePath = normalizedFile.absolutePath;
|
||||
plan.relativeSourcePath = normalizedFile.relativePath;
|
||||
plan.requiresConfirmation = true;
|
||||
|
||||
result.success = true;
|
||||
result.message = QStringLiteral("准备打开:%1").arg(normalizedFile.relativePath);
|
||||
result.targetPath = normalizedFile.absolutePath;
|
||||
result.files.append(normalizedFile);
|
||||
result.hasActionPlan = true;
|
||||
result.actionPlan = plan;
|
||||
return result;
|
||||
}
|
||||
|
||||
WorkspaceToolResult OpenFileTool::execute(const WorkspaceActionPlan &plan) const
|
||||
{
|
||||
WorkspaceToolResult result;
|
||||
if (plan.type != WorkspaceActionType::OpenFile)
|
||||
{
|
||||
result.errorMessage = QStringLiteral("工作区动作类型不匹配。");
|
||||
return result;
|
||||
}
|
||||
|
||||
QString errorMessage;
|
||||
if (!m_policy.canOpenFile(plan.sourcePath, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
const WorkspaceFileInfo file = fileInfoForPath(plan.sourcePath);
|
||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(file.absolutePath)))
|
||||
{
|
||||
result.errorMessage = QStringLiteral("打开文件失败。");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = QStringLiteral("已打开:%1").arg(file.relativePath);
|
||||
result.targetPath = file.absolutePath;
|
||||
result.files.append(file);
|
||||
return result;
|
||||
}
|
||||
|
||||
WorkspaceFileInfo OpenFileTool::fileInfoForPath(const QString &filePath) const
|
||||
{
|
||||
const QFileInfo info(filePath);
|
||||
WorkspaceFileInfo file;
|
||||
file.absolutePath = m_policy.normalizePath(filePath);
|
||||
file.relativePath = m_policy.toRelativePath(file.absolutePath);
|
||||
file.fileName = info.fileName();
|
||||
file.suffix = info.suffix();
|
||||
file.sizeBytes = info.size();
|
||||
file.lastModified = info.lastModified();
|
||||
file.isDirectory = info.isDir();
|
||||
file.isTextFile = !file.isDirectory && m_policy.isSupportedTextFile(file.absolutePath) && !m_policy.isSensitiveFile(file.absolutePath);
|
||||
file.isHidden = info.isHidden();
|
||||
return file;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "ReadFileTool.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr qint64 MaxReadBytes = 128 * 1024;
|
||||
}
|
||||
|
||||
ReadFileTool::ReadFileTool(const WorkspaceAccessPolicy &policy)
|
||||
: m_policy(policy)
|
||||
{
|
||||
}
|
||||
|
||||
WorkspaceToolResult ReadFileTool::run(const WorkspaceFileInfo &file) const
|
||||
{
|
||||
return run(file.absolutePath);
|
||||
}
|
||||
|
||||
WorkspaceToolResult ReadFileTool::run(const QString &filePath) const
|
||||
{
|
||||
WorkspaceToolResult result;
|
||||
const QString absolutePath = m_policy.toAbsolutePath(filePath);
|
||||
QString errorMessage;
|
||||
if (!m_policy.canReadFile(absolutePath, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
QFile file(absolutePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
{
|
||||
result.errorMessage = QStringLiteral("无法读取工作区文件。");
|
||||
return result;
|
||||
}
|
||||
|
||||
const QByteArray content = file.read(MaxReadBytes + 1);
|
||||
QString text = QString::fromUtf8(content.left(MaxReadBytes));
|
||||
if (content.size() > MaxReadBytes)
|
||||
{
|
||||
text += QStringLiteral("\n\n[文件较大,仅显示前 128KB。]");
|
||||
}
|
||||
|
||||
const WorkspaceFileInfo info = fileInfoForPath(absolutePath);
|
||||
result.success = true;
|
||||
result.message = QStringLiteral("已读取:%1").arg(info.relativePath);
|
||||
result.outputText = text;
|
||||
result.targetPath = absolutePath;
|
||||
result.files.append(info);
|
||||
return result;
|
||||
}
|
||||
|
||||
WorkspaceFileInfo ReadFileTool::fileInfoForPath(const QString &filePath) const
|
||||
{
|
||||
const QFileInfo info(filePath);
|
||||
WorkspaceFileInfo file;
|
||||
file.absolutePath = m_policy.normalizePath(filePath);
|
||||
file.relativePath = m_policy.toRelativePath(file.absolutePath);
|
||||
file.fileName = info.fileName();
|
||||
file.suffix = info.suffix();
|
||||
file.sizeBytes = info.size();
|
||||
file.lastModified = info.lastModified();
|
||||
file.isDirectory = info.isDir();
|
||||
file.isTextFile = !file.isDirectory && m_policy.isSupportedTextFile(file.absolutePath) && !m_policy.isSensitiveFile(file.absolutePath);
|
||||
file.isHidden = info.isHidden();
|
||||
return file;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "RevealInFileManagerTool.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QUrl>
|
||||
|
||||
RevealInFileManagerTool::RevealInFileManagerTool(const WorkspaceAccessPolicy &policy)
|
||||
: m_policy(policy)
|
||||
{
|
||||
}
|
||||
|
||||
WorkspaceToolResult RevealInFileManagerTool::run(const WorkspaceFileInfo &item) const
|
||||
{
|
||||
return run(item.absolutePath);
|
||||
}
|
||||
|
||||
WorkspaceToolResult RevealInFileManagerTool::run(const QString &path) const
|
||||
{
|
||||
WorkspaceToolResult result;
|
||||
const QString absolutePath = m_policy.toAbsolutePath(path);
|
||||
const QFileInfo info(absolutePath);
|
||||
QString errorMessage;
|
||||
if (info.isDir())
|
||||
{
|
||||
if (!m_policy.canReadDirectory(absolutePath, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else if (!m_policy.canOpenFile(absolutePath, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
const WorkspaceFileInfo item = itemInfoForPath(absolutePath);
|
||||
const QString directoryToOpen = item.isDirectory ? item.absolutePath : QFileInfo(item.absolutePath).absolutePath();
|
||||
if (!m_policy.canReadDirectory(directoryToOpen, &errorMessage))
|
||||
{
|
||||
result.errorMessage = errorMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(QDir::cleanPath(directoryToOpen))))
|
||||
{
|
||||
result.errorMessage = QStringLiteral("打开所在文件夹失败。");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = item.isDirectory
|
||||
? QStringLiteral("已打开文件夹:%1").arg(item.relativePath)
|
||||
: QStringLiteral("已打开所在文件夹:%1").arg(item.relativePath);
|
||||
result.targetPath = item.absolutePath;
|
||||
result.files.append(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
WorkspaceFileInfo RevealInFileManagerTool::itemInfoForPath(const QString &path) const
|
||||
{
|
||||
const QFileInfo info(path);
|
||||
WorkspaceFileInfo item;
|
||||
item.absolutePath = m_policy.normalizePath(path);
|
||||
item.relativePath = m_policy.toRelativePath(item.absolutePath);
|
||||
item.fileName = info.fileName();
|
||||
item.suffix = info.suffix();
|
||||
item.sizeBytes = info.size();
|
||||
item.lastModified = info.lastModified();
|
||||
item.isDirectory = info.isDir();
|
||||
item.isTextFile = !item.isDirectory && m_policy.isSupportedTextFile(item.absolutePath) && !m_policy.isSensitiveFile(item.absolutePath);
|
||||
item.isHidden = info.isHidden();
|
||||
return item;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
#include "SearchTextTool.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr qint64 MaxSearchReadBytes = 128 * 1024;
|
||||
constexpr int MaxSnippetLength = 180;
|
||||
|
||||
QString trimmedSnippet(const QString &line)
|
||||
{
|
||||
QString snippet = line.simplified();
|
||||
if (snippet.size() > MaxSnippetLength)
|
||||
{
|
||||
snippet = snippet.left(MaxSnippetLength) + QStringLiteral("...");
|
||||
}
|
||||
return snippet;
|
||||
}
|
||||
}
|
||||
|
||||
SearchTextTool::SearchTextTool(const WorkspaceAccessPolicy &policy, const WorkspaceIndex &index)
|
||||
: m_policy(policy)
|
||||
, m_index(index)
|
||||
{
|
||||
}
|
||||
|
||||
WorkspaceToolResult SearchTextTool::run(const QString &keyword, int maxFilesToScan, int maxMatches) const
|
||||
{
|
||||
WorkspaceToolResult result;
|
||||
const QString normalizedKeyword = keyword.trimmed();
|
||||
if (normalizedKeyword.isEmpty())
|
||||
{
|
||||
result.errorMessage = QStringLiteral("请输入要搜索的文本关键词。");
|
||||
return result;
|
||||
}
|
||||
|
||||
int scannedFiles = 0;
|
||||
for (const WorkspaceFileInfo &fileInfo : m_index.allFiles())
|
||||
{
|
||||
if (scannedFiles >= maxFilesToScan || result.textMatches.size() >= maxMatches)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (fileInfo.isDirectory || !fileInfo.isTextFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QString errorMessage;
|
||||
if (!m_policy.canReadFile(fileInfo.absolutePath, &errorMessage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QFile file(fileInfo.absolutePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString text = QString::fromUtf8(file.read(MaxSearchReadBytes));
|
||||
const QStringList lines = text.split(QLatin1Char('\n'));
|
||||
for (int lineIndex = 0; lineIndex < lines.size() && result.textMatches.size() < maxMatches; ++lineIndex)
|
||||
{
|
||||
const QString line = lines.at(lineIndex);
|
||||
if (line.contains(normalizedKeyword, Qt::CaseInsensitive))
|
||||
{
|
||||
result.textMatches.append(WorkspaceTextMatch{
|
||||
fileInfo.relativePath,
|
||||
fileInfo.absolutePath,
|
||||
lineIndex + 1,
|
||||
trimmedSnippet(line),
|
||||
});
|
||||
}
|
||||
}
|
||||
++scannedFiles;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
if (result.textMatches.isEmpty())
|
||||
{
|
||||
result.message = QStringLiteral("没有找到匹配的文本。");
|
||||
return result;
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
lines.append(QStringLiteral("文本搜索:%1").arg(normalizedKeyword));
|
||||
for (int index = 0; index < result.textMatches.size(); ++index)
|
||||
{
|
||||
const WorkspaceTextMatch &match = result.textMatches.at(index);
|
||||
lines.append(QStringLiteral("%1. %2:%3 %4")
|
||||
.arg(index + 1)
|
||||
.arg(match.relativePath)
|
||||
.arg(match.lineNumber)
|
||||
.arg(match.lineText));
|
||||
}
|
||||
result.message = QStringLiteral("找到 %1 条匹配。").arg(result.textMatches.size());
|
||||
result.outputText = lines.join(QLatin1Char('\n'));
|
||||
return result;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user