归档当前功能基线并完善工作区 Agent

This commit is contained in:
2026-07-16 16:23:43 +08:00
parent d8950e7648
commit d0d1de4d00
61 changed files with 10619 additions and 1196 deletions
+34
View File
@@ -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
+38
View File
@@ -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:默认无法确认联网能力,不会尝试旧搜索页抓取。
- 输入框“联网”开关在启用“记住输入框联网开关状态”时会随用户切换立即保存,不依赖发送消息。
配置保存到:
+10 -1
View File
@@ -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
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 或构建命令 |
+31
View File
@@ -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
+1
View File
@@ -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 | 不直接破坏仓库内默认角色包 |
+9
View File
@@ -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:
+1
View File
@@ -9,6 +9,7 @@ enum class CommandDispatchAction
Chat,
Reminder,
Weather,
Workspace,
FileOperation,
LaunchApp,
UnsupportedTool,
+301 -5
View File
@@ -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};
+1
View File
@@ -7,6 +7,7 @@ enum class UserIntentType
Chat,
Reminder,
Weather,
Workspace,
FileOperation,
LaunchApp,
};
+3
View File
@@ -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;
};
+28
View File
@@ -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))
+209
View File
@@ -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;
}
+30
View File
@@ -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;
};
+15
View File
@@ -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)
+1
View File
@@ -19,6 +19,7 @@ private:
void showPetWindow();
void hidePetWindow();
void togglePetWindowVisibility();
void showWorkspacePanel();
void openSettings();
void quitApplication();
+14
View File
@@ -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);
+3
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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;
+97
View File
@@ -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))
+6
View File
@@ -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;
+676
View File
@@ -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;
}
+89
View File
@@ -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;
};
+506
View File
@@ -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;
}
+67
View File
@@ -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;
};
+371
View File
@@ -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;
}
+36
View File
@@ -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;
};
+36
View File
@@ -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));
}
+11
View File
@@ -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
+52
View File
@@ -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;
};
+137
View File
@@ -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();
}
+23
View File
@@ -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;
};
+125
View File
@@ -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;
}
+22
View 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;
};
+90
View File
@@ -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;
}
+44
View File
@@ -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;
};
+15
View File
@@ -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;
};
+97
View File
@@ -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;
};
+52
View File
@@ -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;
}
+15
View File
@@ -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;
};
+56
View File
@@ -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;
}
+18
View File
@@ -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;
};
+85
View File
@@ -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;
}
+18
View 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;
};
+69
View File
@@ -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;
}
+18
View 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;
};
+39
View File
@@ -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;
}
+15
View File
@@ -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;
};
+102
View File
@@ -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;
}
+17
View File
@@ -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;
};