diff --git a/TERM_SCROLLBACK_TOOL.md b/TERM_SCROLLBACK_TOOL.md new file mode 100644 index 000000000..b6b1c8ace --- /dev/null +++ b/TERM_SCROLLBACK_TOOL.md @@ -0,0 +1,218 @@ +# Terminal Scrollback Tool 实现文档 + +## 概述 + +`term_get_scrollback` 工具允许 AI 读取终端的输出历史,而不是直接执行命令。这是一个**只读工具**,完全避免了 AI 重复执行命令的问题。 + +## 架构设计 + +### 后端(Rust) + +1. **TermScrollbackTool** (`src-tauri/src/agent/tools/term_scrollback.rs`) + - 实现 `Tool` trait + - 使用响应通道机制(类似 TerminalTool) + - 通过 Tauri 事件系统与前端通信 + +2. **全局实例管理** + - 使用 `once_cell::sync::Lazy` 创建全局单例 + - 在 `setup.rs` 和 `runner.rs` 中初始化 AppHandle + - 提供 `get_term_scrollback_tool()` 获取全局实例 + +3. **Tauri 命令** + - `agent_term_scrollback_response`: 接收前端的响应 + +### 前端(TypeScript/React) + +1. **API 层** (`src/lib/api/agent.ts`) + - `TermScrollbackRequest`: 请求类型 + - `TermScrollbackResponse`: 响应类型 + - `sendTermScrollbackResponse()`: 发送响应函数 + +2. **Hook 层** (`src/components/terminal/ai/useTerminalAI.ts`) + - 监听 `term_get_scrollback_request` 事件 + - 读取终端输出历史 + - 发送响应给后端 + +## 工作流程 + +``` +AI Agent + ↓ (调用 term_get_scrollback 工具) +TermScrollbackTool + ↓ (发送 term_get_scrollback_request 事件) +前端 useTerminalAI + ↓ (读取终端输出) + ↓ (调用 sendTermScrollbackResponse) +Tauri 命令 agent_term_scrollback_response + ↓ (调用 handle_term_scrollback_response) +TermScrollbackTool + ↓ (通过响应通道返回结果) +AI Agent (收到终端输出) +``` + +## 工具参数 + +```json +{ + "session_id": "终端会话 ID", + "line_start": 0, // 可选,起始行号(从 0 开始) + "count": 50 // 可选,读取行数 +} +``` + +## 工具描述(给 AI 的说明) + +``` +Read terminal output history without executing commands. + +This tool allows you to view the terminal's scrollback buffer (output history) +without executing any commands. Use this to: +- Check the results of previously executed commands +- Review terminal output before suggesting next steps +- Understand the current state of the terminal session + +Parameters: +- session_id: Terminal session ID (required) +- line_start: Starting line number (optional, default: 0) +- count: Number of lines to read (optional, default: all lines) + +Returns the terminal output as plain text. + +IMPORTANT: This is a READ-ONLY tool. It does NOT execute commands. +``` + +## 使用场景 + +### 场景 1:纯只读模式(推荐) + +**配置:** +- 移除 `terminal` 工具 +- 只保留 `term_get_scrollback` 工具 + +**优势:** +- AI 只能读取输出,不能执行命令 +- 完全避免重复执行问题 +- 用户完全控制命令执行 + +**工作流程:** +1. 用户手动在终端执行命令 +2. AI 使用 `term_get_scrollback` 读取输出 +3. AI 根据输出提供建议 +4. 用户决定是否执行 AI 的建议 + +### 场景 2:混合模式(当前实现) + +**配置:** +- 保留 `terminal` 工具(需要审批) +- 添加 `term_get_scrollback` 工具 + +**优势:** +- AI 可以建议命令(需要审批) +- AI 也可以读取历史输出 +- 灵活性更高 + +**工作流程:** +1. AI 建议命令(通过 `terminal` 工具) +2. 用户审批并执行 +3. AI 使用 `term_get_scrollback` 读取输出 +4. AI 根据输出继续工作 + +## 测试步骤 + +### 1. 编译项目 + +```bash +cargo build --manifest-path src-tauri/Cargo.toml +``` + +### 2. 启动应用 + +```bash +npm run tauri dev +``` + +### 3. 测试工具 + +1. 打开终端 +2. 执行一些命令(例如:`ls`, `pwd`, `echo hello`) +3. 打开 AI 面板 +4. 发送消息:`请读取终端的输出历史` +5. AI 应该使用 `term_get_scrollback` 工具读取输出 +6. 检查 AI 是否正确显示了终端输出 + +### 4. 验证日志 + +**后端日志:** +``` +[TermScrollbackTool] 创建全局实例 +[TermScrollbackTool] 设置全局 AppHandle +[TermScrollbackTool] AppHandle 设置成功,已验证 +[TermScrollbackTool] 请求获取滚动缓冲区: session_id=xxx, request_id=xxx +[TermScrollbackTool] 已发送请求到前端: xxx +[TermScrollbackTool] 收到响应: request_id=xxx, success=true +``` + +**前端日志:** +``` +[useTerminalAI] 收到终端滚动缓冲区请求: {...} +[useTerminalAI] 已发送滚动缓冲区响应: 0-50/100 行 +``` + +## 故障排查 + +### 问题 1:AppHandle 未设置 + +**症状:** +``` +[TermScrollbackTool] 警告:AppHandle 未设置!工具将无法正常工作 +``` + +**解决方案:** +- 检查 `setup.rs` 和 `runner.rs` 中是否调用了 `set_term_scrollback_tool_app_handle()` +- 确保在工具注册之前设置 AppHandle + +### 问题 2:前端未收到事件 + +**症状:** +- 后端发送了事件,但前端没有日志 + +**解决方案:** +- 检查 `useTerminalAI` 是否正确监听 `term_get_scrollback_request` 事件 +- 确保 `terminalSessionId` 不为空 +- 检查浏览器控制台是否有错误 + +### 问题 3:响应超时 + +**症状:** +``` +[TermScrollbackTool] 请求超时: xxx +``` + +**解决方案:** +- 检查前端是否正确发送响应 +- 增加超时时间:`TermScrollbackTool::new().with_timeout(60)` +- 检查 `getTerminalOutput()` 函数是否正常工作 + +## 下一步改进 + +1. **添加过滤功能** + - 支持正则表达式过滤 + - 支持关键词搜索 + +2. **添加格式化选项** + - 支持 ANSI 颜色代码 + - 支持纯文本输出 + +3. **添加缓存机制** + - 缓存最近的输出 + - 减少重复读取 + +4. **添加增量读取** + - 只读取新增的输出 + - 支持实时监控 + +## 参考资料 + +- [Waveterm Terminal AI](https://github.com/wavetermdev/waveterm) +- [Tauri Event System](https://tauri.app/v1/guides/features/events/) +- [Tokio Oneshot Channel](https://docs.rs/tokio/latest/tokio/sync/oneshot/) diff --git a/package.json b/package.json index a2d19ec5f..f0eb9a7e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.36.3", + "version": "0.37.0", "type": "module", "repository": { "type": "git", @@ -42,6 +42,7 @@ "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", + "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-shell": "^2.0.0", "@types/lodash-es": "^4.17.12", "@types/styled-components": "^5.1.36", diff --git a/proxycast.db b/proxycast.db new file mode 100644 index 000000000..e69de29bb diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ae5376f8b..e256e5be1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -776,7 +776,7 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation 0.10.1", - "core-graphics", + "core-graphics 0.24.0", "foreign-types 0.5.0", "libc", "objc", @@ -791,7 +791,7 @@ dependencies = [ "bitflags 2.10.0", "block", "core-foundation 0.10.1", - "core-graphics-types", + "core-graphics-types 0.2.0", "objc", ] @@ -882,6 +882,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" @@ -890,11 +903,22 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" @@ -1947,6 +1971,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.17", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2890,6 +2932,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mouse_position" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824feb0675ad2ffda7b1da534f394c5779fb39d123b5f376a221d50fad54b3c2" +dependencies = [ + "core-graphics 0.22.3", + "winapi", + "x11-dl", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -3854,7 +3907,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.36.1" +version = "0.36.3" dependencies = [ "anyhow", "arboard", @@ -3875,6 +3928,7 @@ dependencies = [ "hex", "indexmap 2.12.1", "md5", + "mouse_position", "notify", "objc", "once_cell", @@ -3903,6 +3957,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-shell", "tauri-plugin-single-instance", "tempfile", @@ -3919,6 +3974,7 @@ dependencies = [ "uuid", "whoami", "winapi", + "window-vibrancy 0.7.1", "windows 0.56.0", "winreg 0.52.0", "zip", @@ -5127,7 +5183,7 @@ dependencies = [ "bitflags 2.10.0", "block2", "core-foundation 0.10.1", - "core-graphics", + "core-graphics 0.24.0", "crossbeam-channel", "dispatch", "dlopen2", @@ -5234,7 +5290,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy", + "window-vibrancy 0.6.0", "windows 0.61.3", ] @@ -5393,6 +5449,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.3" @@ -6571,6 +6642,21 @@ dependencies = [ "windows-version", ] +[[package]] +name = "window-vibrancy" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.60.2", + "windows-version", +] + [[package]] name = "windows" version = "0.56.0" @@ -7252,6 +7338,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 222198cb9..cdcdf82d9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.36.3" +version = "0.37.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -15,11 +15,12 @@ crate-type = ["lib", "cdylib", "staticlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon", "image-png", "unstable"] } +tauri = { version = "2", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] } tauri-plugin-shell = "2" tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" tauri-plugin-single-instance = "2" +tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } @@ -72,6 +73,8 @@ ssh2 = "0.9" openssl = { version = "0.10", features = ["vendored"] } sysinfo = "0.32" whoami = "1" +mouse_position = "0.1.4" +window-vibrancy = "0.7.1" # Platform specific dependencies for browser interceptor diff --git a/src-tauri/binaries/aster-server-aarch64-apple-darwin b/src-tauri/binaries/aster-server-aarch64-apple-darwin deleted file mode 120000 index c63a91e57..000000000 --- a/src-tauri/binaries/aster-server-aarch64-apple-darwin +++ /dev/null @@ -1 +0,0 @@ -/Users/coso/Documents/dev/ai/astercloud/aster/aster-server \ No newline at end of file diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fc39ad1e3..89de19dc7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.tauri.app/config/2/capability", "identifier": "default", "description": "Default capabilities for ProxyCast", - "windows": ["main"], + "windows": ["main", "screenshot-chat"], "permissions": [ "core:default", "core:webview:default", @@ -11,12 +11,23 @@ "core:webview:allow-webview-size", "core:webview:allow-set-webview-position", "core:webview:allow-set-webview-size", + "core:window:default", + "core:window:allow-close", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-center", + "core:window:allow-start-dragging", "shell:allow-open", "shell:allow-spawn", "shell:allow-execute", "shell:allow-kill", "shell:allow-stdin-write", "dialog:default", + "global-shortcut:default", + "global-shortcut:allow-is-registered", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister", { "identifier": "shell:allow-execute", "allow": [ diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 17f497fad..d94dc793d 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"autostart":{"default_permission":{"identifier":"default","description":"This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n","permissions":["allow-enable","allow-disable","allow-is-enabled"]},"permissions":{"allow-disable":{"identifier":"allow-disable","description":"Enables the disable command without any pre-configured scope.","commands":{"allow":["disable"],"deny":[]}},"allow-enable":{"identifier":"allow-enable","description":"Enables the enable command without any pre-configured scope.","commands":{"allow":["enable"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"deny-disable":{"identifier":"deny-disable","description":"Denies the disable command without any pre-configured scope.","commands":{"allow":[],"deny":["disable"]}},"deny-enable":{"identifier":"deny-enable","description":"Denies the enable command without any pre-configured scope.","commands":{"allow":[],"deny":["enable"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"deep-link":{"default_permission":{"identifier":"default","description":"Allows reading the opened deep link via the get_current command","permissions":["allow-get-current"]},"permissions":{"allow-get-current":{"identifier":"allow-get-current","description":"Enables the get_current command without any pre-configured scope.","commands":{"allow":["get_current"],"deny":[]}},"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"deny-get-current":{"identifier":"deny-get-current","description":"Denies the get_current command without any pre-configured scope.","commands":{"allow":[],"deny":["get_current"]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"autostart":{"default_permission":{"identifier":"default","description":"This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n","permissions":["allow-enable","allow-disable","allow-is-enabled"]},"permissions":{"allow-disable":{"identifier":"allow-disable","description":"Enables the disable command without any pre-configured scope.","commands":{"allow":["disable"],"deny":[]}},"allow-enable":{"identifier":"allow-enable","description":"Enables the enable command without any pre-configured scope.","commands":{"allow":["enable"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"deny-disable":{"identifier":"deny-disable","description":"Denies the disable command without any pre-configured scope.","commands":{"allow":[],"deny":["disable"]}},"deny-enable":{"identifier":"deny-enable","description":"Denies the enable command without any pre-configured scope.","commands":{"allow":[],"deny":["enable"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"deep-link":{"default_permission":{"identifier":"default","description":"Allows reading the opened deep link via the get_current command","permissions":["allow-get-current"]},"permissions":{"allow-get-current":{"identifier":"allow-get-current","description":"Enables the get_current command without any pre-configured scope.","commands":{"allow":["get_current"],"deny":[]}},"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"deny-get-current":{"identifier":"deny-get-current","description":"Denies the get_current command without any pre-configured scope.","commands":{"allow":[],"deny":["get_current"]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 39dc76af1..832c14fb7 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main","screenshot-chat"],"permissions":["core:default","core:webview:default","core:webview:allow-webview-close","core:webview:allow-webview-position","core:webview:allow-webview-size","core:webview:allow-set-webview-position","core:webview:allow-set-webview-size","core:window:default","core:window:allow-close","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-center","core:window:allow-start-dragging","shell:allow-open","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-stdin-write","dialog:default","global-shortcut:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-unregister",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"binaries/aster-server","sidecar":true}]}]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 0a45d9c6b..26f92f226 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -2396,6 +2396,60 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`", + "type": "string", + "const": "deep-link:default", + "markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`" + }, + { + "description": "Enables the get_current command without any pre-configured scope.", + "type": "string", + "const": "deep-link:allow-get-current", + "markdownDescription": "Enables the get_current command without any pre-configured scope." + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "deep-link:allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "deep-link:allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "deep-link:allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Denies the get_current command without any pre-configured scope.", + "type": "string", + "const": "deep-link:deny-get-current", + "markdownDescription": "Denies the get_current command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "deep-link:deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "deep-link:deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "deep-link:deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, { "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", @@ -2462,6 +2516,72 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n", + "type": "string", + "const": "global-shortcut:default", + "markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n" + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Enables the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register-all", + "markdownDescription": "Enables the register_all command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Enables the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister-all", + "markdownDescription": "Enables the unregister_all command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Denies the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register-all", + "markdownDescription": "Denies the register_all command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister-all", + "markdownDescription": "Denies the unregister_all command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json index 0ad9f7d35..26f92f226 100644 --- a/src-tauri/gen/schemas/macOS-schema.json +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -2516,6 +2516,72 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n", + "type": "string", + "const": "global-shortcut:default", + "markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n" + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Enables the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register-all", + "markdownDescription": "Enables the register_all command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Enables the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister-all", + "markdownDescription": "Enables the unregister_all command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Denies the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register-all", + "markdownDescription": "Denies the register_all command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister-all", + "markdownDescription": "Denies the unregister_all command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", diff --git a/src-tauri/src/agent/native_agent.rs b/src-tauri/src/agent/native_agent.rs index 3816381a1..49e4611fe 100644 --- a/src-tauri/src/agent/native_agent.rs +++ b/src-tauri/src/agent/native_agent.rs @@ -19,7 +19,7 @@ use crate::agent::protocols::{create_protocol, Protocol}; use crate::agent::tool_loop::{ToolCallResult, ToolLoopEngine, ToolLoopState}; -use crate::agent::tools::{create_default_registry, ToolRegistry}; +use crate::agent::tools::{create_default_registry, create_terminal_registry, ToolRegistry}; use crate::agent::types::*; use crate::models::openai::{ ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ContentPart as OpenAIContentPart, @@ -813,8 +813,23 @@ impl NativeAgentState { /// 获取工具注册表 pub fn get_tool_registry(&self) -> Result, String> { + self.get_tool_registry_with_mode(false) + } + + /// 获取工具注册表(支持 Terminal 模式) + /// + /// # Arguments + /// * `terminal_mode` - 是否使用 Terminal 模式(使用 TerminalTool 替代 BashTool) + pub fn get_tool_registry_with_mode( + &self, + terminal_mode: bool, + ) -> Result, String> { let base_dir = dirs::home_dir().ok_or_else(|| "无法获取用户 home 目录".to_string())?; - let registry = create_default_registry(base_dir); + let registry = if terminal_mode { + create_terminal_registry(base_dir) + } else { + create_default_registry(base_dir) + }; Ok(Arc::new(registry)) } diff --git a/src-tauri/src/agent/tools/mod.rs b/src-tauri/src/agent/tools/mod.rs index 9d7a04433..d3b8e48a6 100644 --- a/src-tauri/src/agent/tools/mod.rs +++ b/src-tauri/src/agent/tools/mod.rs @@ -7,6 +7,7 @@ //! - `registry`: 工具注册表和 Tool trait //! - `security`: 安全管理器(路径验证、符号链接检查等) //! - `bash`: Bash 命令执行工具 +//! - `terminal`: 终端命令执行工具(通过前端审批) //! - `read_file`: 文件读取工具 //! - `write_file`: 文件写入工具 //! - `edit_file`: 文件编辑工具 @@ -18,6 +19,8 @@ pub mod prompt; pub mod read_file; pub mod registry; pub mod security; +pub mod term_scrollback; +pub mod terminal; pub mod types; pub mod write_file; @@ -27,6 +30,14 @@ pub use prompt::{generate_tools_prompt, PromptFormat, ToolPromptGenerator}; pub use read_file::{ReadFileResult, ReadFileTool}; pub use registry::{Tool, ToolRegistry}; pub use security::{SecurityError, SecurityManager}; +pub use term_scrollback::{ + get_term_scrollback_tool, handle_term_scrollback_response, set_term_scrollback_tool_app_handle, + GetScrollbackRequest, GetScrollbackResponse, TermScrollbackTool, +}; +pub use terminal::{ + get_terminal_tool, handle_terminal_command_response, set_terminal_tool_app_handle, + TerminalCommandRequest, TerminalCommandResponse, TerminalTool, +}; pub use types::*; pub use write_file::{WriteFileResult, WriteFileTool}; @@ -70,3 +81,71 @@ pub fn create_default_registry(base_dir: impl AsRef) -> ToolRegistry { registry } + +/// 创建 Terminal AI 模式的工具注册表 +/// +/// 使用 TerminalTool 替代 BashTool,命令通过前端审批后在用户终端执行 +/// 同时添加 TermScrollbackTool 用于只读访问终端输出 +/// +/// # Arguments +/// * `base_dir` - 基础目录,所有文件操作必须在此目录内 +/// +/// # Returns +/// 包含 terminal, term_get_scrollback, read_file, write_file, edit_file 工具的注册表 +pub fn create_terminal_registry(base_dir: impl AsRef) -> ToolRegistry { + let security = Arc::new(SecurityManager::new(base_dir.as_ref())); + let registry = ToolRegistry::new(); + + // 注册 TerminalTool(替代 BashTool) + // 使用全局实例以便前端可以响应 + let terminal_tool = get_terminal_tool(); + + // 验证 AppHandle 是否已设置 + if terminal_tool.is_app_handle_set() { + tracing::info!("[Tools] TerminalTool AppHandle 已设置,可以正常使用"); + } else { + tracing::error!("[Tools] 警告:TerminalTool AppHandle 未设置!工具将无法正常工作"); + eprintln!("[Tools] 警告:TerminalTool AppHandle 未设置!工具将无法正常工作"); + } + + if let Err(e) = registry.register_arc(terminal_tool) { + tracing::error!("注册 TerminalTool 失败: {}", e); + } + + // 注册 TermScrollbackTool(只读访问终端输出) + // 使用全局实例以便前端可以响应 + let scrollback_tool = get_term_scrollback_tool(); + + // 验证 AppHandle 是否已设置 + if scrollback_tool.is_app_handle_set() { + tracing::info!("[Tools] TermScrollbackTool AppHandle 已设置,可以正常使用"); + } else { + tracing::error!("[Tools] 警告:TermScrollbackTool AppHandle 未设置!工具将无法正常工作"); + eprintln!("[Tools] 警告:TermScrollbackTool AppHandle 未设置!工具将无法正常工作"); + } + + if let Err(e) = registry.register_arc(scrollback_tool) { + tracing::error!("注册 TermScrollbackTool 失败: {}", e); + } + + // 注册文件操作工具 + if let Err(e) = registry.register(ReadFileTool::new(Arc::clone(&security))) { + tracing::error!("注册 ReadFileTool 失败: {}", e); + } + + if let Err(e) = registry.register(WriteFileTool::new(Arc::clone(&security))) { + tracing::error!("注册 WriteFileTool 失败: {}", e); + } + + if let Err(e) = registry.register(EditFileTool::new(Arc::clone(&security))) { + tracing::error!("注册 EditFileTool 失败: {}", e); + } + + info!( + "[Tools] 已创建 Terminal AI 工具注册表,共 {} 个工具: {:?}", + registry.len(), + registry.list_names() + ); + + registry +} diff --git a/src-tauri/src/agent/tools/term_scrollback.rs b/src-tauri/src/agent/tools/term_scrollback.rs new file mode 100644 index 000000000..2fb2b39b7 --- /dev/null +++ b/src-tauri/src/agent/tools/term_scrollback.rs @@ -0,0 +1,346 @@ +//! Terminal Scrollback 工具模块 +//! +//! 提供只读访问终端输出历史的功能 +//! 参考 Waveterm 的 term_get_scrollback 工具设计 + +use super::registry::Tool; +use super::types::{JsonSchema, PropertySchema, ToolDefinition, ToolError, ToolResult}; +use async_trait::async_trait; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +/// 默认超时时间(秒) +const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// 获取滚动缓冲区的请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetScrollbackRequest { + /// 请求 ID + pub request_id: String, + /// 终端会话 ID + pub session_id: String, + /// 起始行(可选,默认从最后 200 行开始) + pub line_start: Option, + /// 行数(可选,默认 200 行) + pub count: Option, +} + +/// 获取滚动缓冲区的响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetScrollbackResponse { + /// 请求 ID + pub request_id: String, + /// 是否成功 + pub success: bool, + /// 总行数 + pub total_lines: usize, + /// 起始行 + pub line_start: usize, + /// 结束行 + pub line_end: usize, + /// 内容 + pub content: String, + /// 是否有更多数据 + pub has_more: bool, + /// 错误信息 + pub error: Option, +} + +/// 待处理的请求(内部使用) +pub(crate) struct PendingRequest { + /// 响应发送器 + response_tx: oneshot::Sender, +} + +/// Terminal Scrollback 工具 +/// +/// 允许 AI 读取终端的输出历史,而不是直接执行命令 +pub struct TermScrollbackTool { + /// 待处理的请求 + pending_requests: Arc>>, + /// 默认超时时间(秒) + timeout_secs: u64, + /// Tauri AppHandle(用于发送事件) + app_handle: Arc>>, +} + +impl TermScrollbackTool { + /// 创建新的 TermScrollbackTool + pub fn new() -> Self { + Self { + pending_requests: Arc::new(RwLock::new(HashMap::new())), + timeout_secs: DEFAULT_TIMEOUT_SECS, + app_handle: Arc::new(RwLock::new(None)), + } + } + + /// 设置超时时间 + pub fn with_timeout(mut self, timeout_secs: u64) -> Self { + self.timeout_secs = timeout_secs; + self + } + + /// 设置 Tauri AppHandle + pub fn set_app_handle(&self, handle: tauri::AppHandle) { + let mut app_handle = self.app_handle.write(); + *app_handle = Some(handle); + eprintln!("[TermScrollbackTool] AppHandle 已设置"); + tracing::info!("[TermScrollbackTool] AppHandle 已设置"); + } + + /// 检查 AppHandle 是否已设置 + pub fn is_app_handle_set(&self) -> bool { + let app_handle = self.app_handle.read(); + app_handle.is_some() + } + + /// 处理响应(由前端调用) + pub fn handle_response(&self, response: GetScrollbackResponse) { + let request_id = response.request_id.clone(); + + let pending = { + let mut requests = self.pending_requests.write(); + requests.remove(&request_id) + }; + + if let Some(pending) = pending { + if pending.response_tx.send(response).is_err() { + warn!( + "[TermScrollbackTool] 发送响应失败,接收端已关闭: {}", + request_id + ); + } + } else { + warn!("[TermScrollbackTool] 未找到待处理的请求: {}", request_id); + } + } + + /// 获取终端滚动缓冲区 + async fn get_scrollback( + &self, + session_id: &str, + line_start: Option, + count: Option, + ) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + + info!( + "[TermScrollbackTool] 请求获取滚动缓冲区: session_id={}, request_id={}", + session_id, request_id + ); + + // 创建响应通道 + let (response_tx, response_rx) = oneshot::channel(); + + // 添加到待处理列表 + { + let mut requests = self.pending_requests.write(); + requests.insert(request_id.clone(), PendingRequest { response_tx }); + } + + // 构建请求 + let request = GetScrollbackRequest { + request_id: request_id.clone(), + session_id: session_id.to_string(), + line_start, + count, + }; + + // 发送事件到前端 + { + let app_handle = self.app_handle.read(); + eprintln!( + "[TermScrollbackTool] 检查 AppHandle: is_some={}", + app_handle.is_some() + ); + + if let Some(handle) = app_handle.as_ref() { + use tauri::Emitter; + eprintln!("[TermScrollbackTool] 尝试发送事件到前端: {}", request_id); + if let Err(e) = handle.emit("term_get_scrollback_request", &request) { + // 清理待处理请求 + let mut requests = self.pending_requests.write(); + requests.remove(&request_id); + + warn!("[TermScrollbackTool] 发送事件到前端失败: {}", e); + return Err(ToolError::ExecutionFailed(format!( + "无法发送请求到前端:{}", + e + ))); + } + debug!("[TermScrollbackTool] 已发送请求到前端: {}", request_id); + } else { + // 清理待处理请求 + let mut requests = self.pending_requests.write(); + requests.remove(&request_id); + eprintln!("[TermScrollbackTool] AppHandle 为 None,无法发送事件"); + + warn!("[TermScrollbackTool] AppHandle 未设置"); + return Err(ToolError::ExecutionFailed( + "TermScrollbackTool 未正确初始化".to_string(), + )); + } + } + + // 等待响应(带超时) + let timeout_duration = Duration::from_secs(self.timeout_secs); + match timeout(timeout_duration, response_rx).await { + Ok(Ok(response)) => { + debug!( + "[TermScrollbackTool] 收到响应: request_id={}, success={}", + response.request_id, response.success + ); + Ok(response) + } + Ok(Err(_)) => { + // 通道关闭 + let mut requests = self.pending_requests.write(); + requests.remove(&request_id); + Err(ToolError::ExecutionFailed("响应通道已关闭".to_string())) + } + Err(_) => { + // 超时 + warn!("[TermScrollbackTool] 请求超时: {}", request_id); + let mut requests = self.pending_requests.write(); + requests.remove(&request_id); + Err(ToolError::Timeout) + } + } + } +} + +impl Default for TermScrollbackTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for TermScrollbackTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new( + "term_get_scrollback", + "Get the terminal output history (scrollback buffer). This is a READ-ONLY tool \ + that allows you to view what has been output in the terminal.\n\n\ + IMPORTANT: This tool does NOT execute commands. It only reads the terminal output. \ + Use this to:\n\ + - Check the results of commands that the user has run\n\ + - View error messages and logs\n\ + - Understand the current state of the terminal\n\n\ + The user must manually execute commands in their terminal. You can suggest commands \ + for the user to run, but you cannot execute them directly with this tool.", + ) + .with_parameters( + JsonSchema::new() + .add_property( + "session_id", + PropertySchema::string( + "The terminal session ID to read from. This is provided by the system.", + ), + true, + ) + .add_property( + "line_start", + PropertySchema::integer( + "Optional starting line number. If not specified, returns the last 200 lines.", + ), + false, + ) + .add_property( + "count", + PropertySchema::integer( + "Optional number of lines to return. Defaults to 200 lines.", + ) + .with_default(serde_json::json!(200)), + false, + ), + ) + } + + async fn execute(&self, args: serde_json::Value) -> Result { + // 解析参数 + let session_id = args + .get("session_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidArguments("缺少 session_id 参数".to_string()))?; + + let line_start = args + .get("line_start") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + let count = args + .get("count") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + + // 获取滚动缓冲区 + let response = self.get_scrollback(session_id, line_start, count).await?; + + // 构建输出 + if response.success { + let mut output = format!( + "Terminal output (lines {}-{} of {}):\n\n", + response.line_start, response.line_end, response.total_lines + ); + output.push_str(&response.content); + + if response.has_more { + output.push_str( + "\n\n[More output available. Use line_start parameter to fetch earlier lines.]", + ); + } + + Ok(ToolResult::success(output)) + } else { + let error_msg = response + .error + .unwrap_or_else(|| "Unknown error".to_string()); + Err(ToolError::ExecutionFailed(error_msg)) + } + } +} + +/// 全局 TermScrollbackTool 实例 +static TERM_SCROLLBACK_TOOL: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| { + eprintln!("[TermScrollbackTool] 创建全局实例"); + Arc::new(TermScrollbackTool::new()) + }); + +/// 获取全局 TermScrollbackTool 实例 +pub fn get_term_scrollback_tool() -> Arc { + eprintln!("[TermScrollbackTool] 获取全局实例"); + Arc::clone(&TERM_SCROLLBACK_TOOL) +} + +/// 设置全局 TermScrollbackTool 的 AppHandle +pub fn set_term_scrollback_tool_app_handle(handle: tauri::AppHandle) { + eprintln!("[TermScrollbackTool] 设置全局 AppHandle"); + tracing::info!("[TermScrollbackTool] 设置全局 AppHandle"); + + // 强制初始化全局实例(如果还未初始化) + let _ = &*TERM_SCROLLBACK_TOOL; + + TERM_SCROLLBACK_TOOL.set_app_handle(handle); + + // 验证设置是否成功 + let app_handle = TERM_SCROLLBACK_TOOL.app_handle.read(); + if app_handle.is_some() { + eprintln!("[TermScrollbackTool] AppHandle 设置成功,已验证"); + tracing::info!("[TermScrollbackTool] AppHandle 设置成功,已验证"); + } else { + eprintln!("[TermScrollbackTool] 警告:AppHandle 设置后仍为 None"); + tracing::error!("[TermScrollbackTool] 警告:AppHandle 设置后仍为 None"); + } +} + +/// 处理滚动缓冲区响应(由 Tauri 命令调用) +pub fn handle_term_scrollback_response(response: GetScrollbackResponse) { + TERM_SCROLLBACK_TOOL.handle_response(response); +} diff --git a/src-tauri/src/agent/tools/terminal.rs b/src-tauri/src/agent/tools/terminal.rs new file mode 100644 index 000000000..9e1662e77 --- /dev/null +++ b/src-tauri/src/agent/tools/terminal.rs @@ -0,0 +1,499 @@ +//! Terminal 工具模块 +//! +//! 提供终端命令执行功能,通过 Tauri 事件与前端通信 +//! 支持命令审批流程,命令在用户的实际终端中执行 +//! +//! ## 工作流程 +//! 1. AI 调用 terminal 工具 +//! 2. 工具发送事件到前端请求执行命令 +//! 3. 前端显示审批 UI +//! 4. 用户批准后,命令发送到实际终端 +//! 5. 终端执行结果返回给 AI + +use super::registry::Tool; +use super::types::{JsonSchema, PropertySchema, ToolDefinition, ToolError, ToolResult}; +use async_trait::async_trait; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +/// 默认超时时间(秒) +const DEFAULT_TIMEOUT_SECS: u64 = 120; + +/// 命令执行请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalCommandRequest { + /// 请求 ID + pub request_id: String, + /// 要执行的命令 + pub command: String, + /// 工作目录(可选) + pub working_dir: Option, + /// 超时时间(秒) + pub timeout_secs: u64, +} + +/// 命令执行响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalCommandResponse { + /// 请求 ID + pub request_id: String, + /// 是否成功 + pub success: bool, + /// 输出内容 + pub output: String, + /// 错误信息 + pub error: Option, + /// 退出码 + pub exit_code: Option, + /// 是否被用户拒绝 + pub rejected: bool, +} + +/// 命令执行状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandStatus { + /// 等待审批 + Pending, + /// 已批准,执行中 + Executing, + /// 已完成 + Completed, + /// 已拒绝 + Rejected, + /// 超时 + Timeout, +} + +/// 待处理的命令(内部使用) +pub(crate) struct PendingCommand { + /// 响应发送器 + response_tx: oneshot::Sender, +} + +/// 已执行命令的记录 +#[derive(Debug, Clone)] +struct ExecutedCommand { + /// 命令内容 + command: String, + /// 执行时间 + executed_at: std::time::Instant, + /// 是否成功 + success: bool, +} + +/// 重复命令检测的时间窗口(秒) +const DUPLICATE_DETECTION_WINDOW_SECS: u64 = 30; + +/// Terminal 工具 +/// +/// 通过 Tauri 事件与前端通信,在用户终端中执行命令 +pub struct TerminalTool { + /// 待处理的命令 + pending_commands: Arc>>, + /// 已执行的命令历史(用于检测重复) + executed_commands: Arc>>, + /// 默认超时时间(秒) + timeout_secs: u64, + /// Tauri AppHandle(用于发送事件) + app_handle: Arc>>, +} + +impl TerminalTool { + /// 创建新的 Terminal 工具 + pub fn new() -> Self { + Self { + pending_commands: Arc::new(RwLock::new(HashMap::new())), + executed_commands: Arc::new(RwLock::new(Vec::new())), + timeout_secs: DEFAULT_TIMEOUT_SECS, + app_handle: Arc::new(RwLock::new(None)), + } + } + + /// 设置超时时间 + pub fn with_timeout(mut self, timeout_secs: u64) -> Self { + self.timeout_secs = timeout_secs; + self + } + + /// 设置 Tauri AppHandle + pub fn set_app_handle(&self, handle: tauri::AppHandle) { + let mut app_handle = self.app_handle.write(); + *app_handle = Some(handle); + eprintln!("[TerminalTool] AppHandle 已设置"); + tracing::info!("[TerminalTool] AppHandle 已设置"); + } + + /// 检查 AppHandle 是否已设置 + pub fn is_app_handle_set(&self) -> bool { + let app_handle = self.app_handle.read(); + app_handle.is_some() + } + + /// 处理命令响应(由前端调用) + pub fn handle_response(&self, response: TerminalCommandResponse) { + let request_id = response.request_id.clone(); + + let pending = { + let mut commands = self.pending_commands.write(); + commands.remove(&request_id) + }; + + if let Some(pending) = pending { + if pending.response_tx.send(response).is_err() { + warn!("[TerminalTool] 发送响应失败,接收端已关闭: {}", request_id); + } + } else { + warn!("[TerminalTool] 未找到待处理的命令: {}", request_id); + } + } + + /// 检查命令是否在最近执行过(用于防止重复执行) + fn check_duplicate_command(&self, command: &str) -> Option<&'static str> { + let now = std::time::Instant::now(); + let window = Duration::from_secs(DUPLICATE_DETECTION_WINDOW_SECS); + + // 清理过期的记录 + { + let mut history = self.executed_commands.write(); + history.retain(|cmd| now.duration_since(cmd.executed_at) < window); + } + + // 检查是否有重复 + let history = self.executed_commands.read(); + for cmd in history.iter() { + if cmd.command == command && cmd.success { + return Some( + "This exact command was already executed successfully within the last 30 seconds. \ + Do NOT re-execute it. If you need to verify the result, use the term_get_scrollback \ + tool to read the terminal output." + ); + } + } + + None + } + + /// 记录已执行的命令 + fn record_executed_command(&self, command: &str, success: bool) { + let mut history = self.executed_commands.write(); + history.push(ExecutedCommand { + command: command.to_string(), + executed_at: std::time::Instant::now(), + success, + }); + + // 限制历史记录大小 + if history.len() > 100 { + history.remove(0); + } + } + + /// 执行命令 + async fn execute_command( + &self, + command: &str, + working_dir: Option<&str>, + timeout_secs: u64, + ) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + + info!( + "[TerminalTool] 请求执行命令: {} (request_id: {}, timeout: {}s)", + command, request_id, timeout_secs + ); + + // 创建响应通道 + let (response_tx, response_rx) = oneshot::channel(); + + // 添加到待处理列表 + { + let mut commands = self.pending_commands.write(); + commands.insert(request_id.clone(), PendingCommand { response_tx }); + } + + // 构建请求 + let request = TerminalCommandRequest { + request_id: request_id.clone(), + command: command.to_string(), + working_dir: working_dir.map(|s| s.to_string()), + timeout_secs, + }; + + // 发送事件到前端 + { + let app_handle = self.app_handle.read(); + eprintln!( + "[TerminalTool] 检查 AppHandle: is_some={}", + app_handle.is_some() + ); + + if let Some(handle) = app_handle.as_ref() { + use tauri::Emitter; + eprintln!("[TerminalTool] 尝试发送事件到前端: {}", request_id); + if let Err(e) = handle.emit("terminal_command_request", &request) { + // 清理待处理命令 + let mut commands = self.pending_commands.write(); + commands.remove(&request_id); + + // 返回失败响应而不是错误,避免 Agent 重试 + warn!("[TerminalTool] 发送事件到前端失败: {}", e); + return Ok(TerminalCommandResponse { + request_id: request_id.clone(), + success: false, + output: String::new(), + error: Some(format!("无法发送命令到终端:{}。请检查应用配置。", e)), + exit_code: Some(-1), + rejected: false, + }); + } + debug!("[TerminalTool] 已发送命令请求到前端: {}", request_id); + } else { + // 清理待处理命令 + let mut commands = self.pending_commands.write(); + commands.remove(&request_id); + eprintln!("[TerminalTool] AppHandle 为 None,无法发送事件"); + + // 返回失败响应而不是错误,避免 Agent 重试 + warn!("[TerminalTool] AppHandle 未设置"); + return Ok(TerminalCommandResponse { + request_id: request_id.clone(), + success: false, + output: String::new(), + error: Some( + "终端工具未正确初始化。这是一个应用程序配置问题,请联系开发者。\n\ + 作为替代方案,我可以为您提供命令建议,但无法直接执行。" + .to_string(), + ), + exit_code: Some(-1), + rejected: false, + }); + } + } + + // 等待响应(带超时) + let timeout_duration = Duration::from_secs(timeout_secs); + match timeout(timeout_duration, response_rx).await { + Ok(Ok(response)) => { + debug!( + "[TerminalTool] 收到响应: request_id={}, success={}", + response.request_id, response.success + ); + Ok(response) + } + Ok(Err(_)) => { + // 通道关闭 + let mut commands = self.pending_commands.write(); + commands.remove(&request_id); + Err(ToolError::ExecutionFailed("响应通道已关闭".to_string())) + } + Err(_) => { + // 超时 + warn!("[TerminalTool] 命令执行超时: {}", request_id); + let mut commands = self.pending_commands.write(); + commands.remove(&request_id); + Err(ToolError::Timeout) + } + } + } +} + +impl Default for TerminalTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for TerminalTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new( + "terminal", + "Execute a command in the user's terminal. The command will be sent to the \ + active terminal session and requires user approval before execution.\n\n\ + CRITICAL RULES:\n\ + 1. NEVER re-execute a command that has already succeeded. When you receive \ + '[COMMAND EXECUTED SUCCESSFULLY]' in the response, the command is DONE.\n\ + 2. Each command runs exactly ONCE. Do not retry successful commands.\n\ + 3. If you need to verify the result, use the term_get_scrollback tool to \ + read the terminal output instead of re-running the command.\n\n\ + Use this for running system commands, scripts, or any command-line operations \ + that should be visible to the user.", + ) + .with_parameters( + JsonSchema::new() + .add_property( + "command", + PropertySchema::string( + "The command to execute in the terminal. Can be any valid shell command.", + ), + true, + ) + .add_property( + "working_dir", + PropertySchema::string( + "Optional working directory for the command. If not specified, \ + uses the terminal's current directory.", + ), + false, + ) + .add_property( + "timeout", + PropertySchema::integer( + "Optional timeout in seconds. Defaults to 120 seconds.", + ) + .with_default(serde_json::json!(120)), + false, + ), + ) + } + + async fn execute(&self, args: serde_json::Value) -> Result { + // 解析参数 + let command = args + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidArguments("缺少 command 参数".to_string()))?; + + let working_dir = args.get("working_dir").and_then(|v| v.as_str()); + + let timeout_secs = args + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(self.timeout_secs); + + // 检查是否是重复命令 + if let Some(duplicate_msg) = self.check_duplicate_command(command) { + warn!("[TerminalTool] 检测到重复命令: {}", command); + return Ok(ToolResult::success(format!( + "[DUPLICATE COMMAND BLOCKED]\n{}\n\nOriginal command: {}", + duplicate_msg, command + ))); + } + + // 执行命令 + let response = self + .execute_command(command, working_dir, timeout_secs) + .await?; + + // 记录已执行的命令 + self.record_executed_command(command, response.success); + + // 构建输出 + if response.rejected { + return Ok(ToolResult::failure_with_output( + "用户拒绝执行此命令".to_string(), + "命令被用户拒绝".to_string(), + )); + } + + if response.success { + Ok(ToolResult::success(response.output)) + } else { + let error_msg = response.error.unwrap_or_else(|| { + format!( + "命令执行失败 (退出码: {})", + response.exit_code.unwrap_or(-1) + ) + }); + Ok(ToolResult::failure_with_output(response.output, error_msg)) + } + } +} + +/// 全局 TerminalTool 实例 +static TERMINAL_TOOL: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| { + eprintln!("[TerminalTool] 创建全局实例"); + Arc::new(TerminalTool::new()) +}); + +/// 获取全局 TerminalTool 实例 +pub fn get_terminal_tool() -> Arc { + eprintln!("[TerminalTool] 获取全局实例"); + Arc::clone(&TERMINAL_TOOL) +} + +/// 设置全局 TerminalTool 的 AppHandle +pub fn set_terminal_tool_app_handle(handle: tauri::AppHandle) { + eprintln!("[TerminalTool] 设置全局 AppHandle"); + tracing::info!("[TerminalTool] 设置全局 AppHandle"); + + // 强制初始化全局实例(如果还未初始化) + let _ = &*TERMINAL_TOOL; + + TERMINAL_TOOL.set_app_handle(handle); + + // 验证设置是否成功 + let app_handle = TERMINAL_TOOL.app_handle.read(); + if app_handle.is_some() { + eprintln!("[TerminalTool] AppHandle 设置成功,已验证"); + tracing::info!("[TerminalTool] AppHandle 设置成功,已验证"); + } else { + eprintln!("[TerminalTool] 警告:AppHandle 设置后仍为 None"); + tracing::error!("[TerminalTool] 警告:AppHandle 设置后仍为 None"); + } +} + +/// 处理终端命令响应(由 Tauri 命令调用) +pub fn handle_terminal_command_response(response: TerminalCommandResponse) { + TERMINAL_TOOL.handle_response(response); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_definition() { + let tool = TerminalTool::new(); + let def = tool.definition(); + + assert_eq!(def.name, "terminal"); + assert!(!def.description.is_empty()); + assert!(def.parameters.required.contains(&"command".to_string())); + } + + #[test] + fn test_terminal_command_request_serialization() { + let request = TerminalCommandRequest { + request_id: "test-123".to_string(), + command: "echo hello".to_string(), + working_dir: Some("/home/user".to_string()), + timeout_secs: 60, + }; + + let json = serde_json::to_string(&request).unwrap(); + let parsed: TerminalCommandRequest = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.request_id, "test-123"); + assert_eq!(parsed.command, "echo hello"); + assert_eq!(parsed.working_dir, Some("/home/user".to_string())); + assert_eq!(parsed.timeout_secs, 60); + } + + #[test] + fn test_terminal_command_response_serialization() { + let response = TerminalCommandResponse { + request_id: "test-123".to_string(), + success: true, + output: "hello\n".to_string(), + error: None, + exit_code: Some(0), + rejected: false, + }; + + let json = serde_json::to_string(&response).unwrap(); + let parsed: TerminalCommandResponse = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.request_id, "test-123"); + assert!(parsed.success); + assert_eq!(parsed.output, "hello\n"); + assert_eq!(parsed.exit_code, Some(0)); + assert!(!parsed.rejected); + } +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 0bdb95a2c..6565fd842 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -70,7 +70,7 @@ pub fn run() { native_agent: native_agent_state, oauth_plugin_manager: oauth_plugin_manager_state, orchestrator: orchestrator_state, - connect_state: connect_state, + connect_state, model_registry: model_registry_state, global_config_manager: global_config_manager_state, terminal_manager: terminal_manager_state, @@ -97,6 +97,7 @@ pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--minimized"]), @@ -179,6 +180,14 @@ pub fn run() { } }) .setup(move |app| { + // 设置 TerminalTool 的 AppHandle(用于发送事件到前端) + crate::agent::tools::set_terminal_tool_app_handle(app.handle().clone()); + tracing::info!("[启动] TerminalTool AppHandle 已设置"); + + // 设置 TermScrollbackTool 的 AppHandle(用于发送事件到前端) + crate::agent::tools::set_term_scrollback_tool_app_handle(app.handle().clone()); + tracing::info!("[启动] TermScrollbackTool AppHandle 已设置"); + // 初始化托盘管理器 // Requirements 1.4: 应用启动时显示停止状态图标 match TrayManager::new(app.handle()) { @@ -206,6 +215,21 @@ pub fn run() { tracing::info!("[启动] GlobalConfigManager AppHandle 已设置"); } + // 初始化截图对话模块 + // _Requirements: 7.3_ + { + let app_handle = app.handle(); + match crate::screenshot::init(app_handle) { + Ok(()) => { + tracing::info!("[启动] 截图对话模块初始化成功"); + } + Err(e) => { + tracing::error!("[启动] 截图对话模块初始化失败: {}", e); + // 截图模块初始化失败不影响应用运行 + } + } + } + // 初始化 Connect 状态 // _Requirements: 1.4, 2.1_ { @@ -240,7 +264,36 @@ pub fn run() { let app_handle = app.handle().clone(); let db_clone = db_clone.clone(); // 获取资源目录路径 - let resource_dir = app.path().resource_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let mut resource_dir = app.path().resource_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + + // 检查 resources/models/index.json 是否存在 + let models_index = resource_dir.join("resources/models/index.json"); + + if !models_index.exists() { + // 开发模式:尝试多个可能的路径 + let possible_paths = [ + // 从 target/debug 回退到 src-tauri + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .map(|p| p.join("resources")), + // 直接使用 target/debug/resources + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())), + // 使用当前工作目录 + std::env::current_dir().ok().map(|p| p.join("src-tauri")), + ]; + + for path in possible_paths.into_iter().flatten() { + let test_index = path.join("resources/models/index.json"); + if test_index.exists() { + resource_dir = path; + break; + } + } + } + tauri::async_runtime::spawn(async move { // 创建 ModelRegistryService let mut service = crate::services::model_registry_service::ModelRegistryService::new(db_clone); @@ -947,6 +1000,8 @@ pub fn run() { commands::agent_cmd::agent_list_sessions, commands::agent_cmd::agent_get_session, commands::agent_cmd::agent_delete_session, + commands::agent_cmd::agent_terminal_command_response, + commands::agent_cmd::agent_term_scrollback_response, // Native Agent commands commands::native_agent_cmd::native_agent_init, commands::native_agent_cmd::native_agent_status, @@ -1035,7 +1090,7 @@ pub fn run() { commands::connect_cmd::send_connect_callback, // Model Registry commands commands::model_registry_cmd::get_model_registry, - // commands::model_registry_cmd::refresh_model_registry, // TODO: 暂时禁用 + commands::model_registry_cmd::refresh_model_registry, commands::model_registry_cmd::search_models, commands::model_registry_cmd::get_model_preferences, commands::model_registry_cmd::toggle_model_favorite, @@ -1086,6 +1141,18 @@ pub fn run() { commands::webview_cmd::resize_webview_panel, commands::webview_cmd::get_webview_panels, commands::webview_cmd::focus_webview_panel, + // Screenshot Chat commands + // _Requirements: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_ + commands::screenshot_cmd::get_experimental_config, + commands::screenshot_cmd::save_experimental_config, + commands::screenshot_cmd::start_screenshot, + commands::screenshot_cmd::validate_shortcut, + commands::screenshot_cmd::update_screenshot_shortcut, + commands::screenshot_cmd::close_screenshot_chat_window, + commands::screenshot_cmd::read_image_as_base64, + commands::screenshot_cmd::send_screenshot_chat, + commands::screenshot_cmd::close_screenshot_chat_window, + commands::screenshot_cmd::read_image_as_base64, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 04bff8a21..0053d9b4a 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use tauri::{App, Manager}; +use crate::agent::tools::{set_term_scrollback_tool_app_handle, set_terminal_tool_app_handle}; use crate::agent::NativeAgentState; use crate::commands::oauth_plugin_cmd::OAuthPluginManagerState; use crate::database; @@ -52,6 +53,14 @@ pub fn setup_app( let native_agent_state = NativeAgentState::new(); app.manage(native_agent_state); + // 设置 TerminalTool 的 AppHandle(用于发送事件到前端) + set_terminal_tool_app_handle(app.handle().clone()); + tracing::info!("[启动] TerminalTool AppHandle 已设置"); + + // 设置 TermScrollbackTool 的 AppHandle(用于发送事件到前端) + set_term_scrollback_tool_app_handle(app.handle().clone()); + tracing::info!("[启动] TermScrollbackTool AppHandle 已设置"); + // 初始化 OAuth Plugin Manager State let oauth_plugin_manager_state = OAuthPluginManagerState::with_defaults(); app.manage(oauth_plugin_manager_state); diff --git a/src-tauri/src/browser_interceptor/interceptor.rs b/src-tauri/src/browser_interceptor/interceptor.rs index 89e0311d7..a7da381ba 100644 --- a/src-tauri/src/browser_interceptor/interceptor.rs +++ b/src-tauri/src/browser_interceptor/interceptor.rs @@ -370,7 +370,6 @@ impl BrowserInterceptor { } /// 复制文本到剪贴板 - #[cfg(feature = "tauri-app")] async fn copy_to_clipboard(&self, text: &str) -> Result<()> { match arboard::Clipboard::new() { Ok(mut clipboard) => { @@ -390,12 +389,6 @@ impl BrowserInterceptor { } } - #[cfg(not(feature = "tauri-app"))] - async fn copy_to_clipboard(&self, _text: &str) -> Result<()> { - tracing::warn!("剪贴板功能需要 tauri-app feature"); - Ok(()) - } - /// 启动指纹浏览器 async fn launch_fingerprint_browser( &self, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index 6de78bcc6..4bab1b9fd 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -2,6 +2,10 @@ //! //! 提供原生 Agent 的 Tauri 命令(兼容旧 API) +use crate::agent::tools::{ + handle_term_scrollback_response, handle_terminal_command_response, GetScrollbackResponse, + TerminalCommandResponse, +}; use crate::agent::{ImageData, NativeAgentState, NativeChatRequest, ProviderType}; use crate::AppState; use serde::{Deserialize, Serialize}; @@ -358,3 +362,72 @@ pub async fn agent_delete_session( Err("会话不存在".to_string()) } } + +/// 处理终端命令响应 +/// +/// 前端在用户批准/拒绝命令后调用此命令,将结果传递给 TerminalTool +#[tauri::command] +pub async fn agent_terminal_command_response( + request_id: String, + success: bool, + output: String, + error: Option, + exit_code: Option, + rejected: bool, +) -> Result<(), String> { + tracing::info!( + "[Agent] 收到终端命令响应: request_id={}, success={}, rejected={}", + request_id, + success, + rejected + ); + + let response = TerminalCommandResponse { + request_id, + success, + output, + error, + exit_code, + rejected, + }; + + handle_terminal_command_response(response); + + Ok(()) +} + +/// 前端返回终端滚动缓冲区数据 +#[tauri::command] +pub async fn agent_term_scrollback_response( + request_id: String, + success: bool, + total_lines: usize, + line_start: usize, + line_end: usize, + content: String, + has_more: bool, + error: Option, +) -> Result<(), String> { + tracing::info!( + "[Agent] 收到终端滚动缓冲区响应: request_id={}, success={}, lines={}-{}", + request_id, + success, + line_start, + line_end + ); + + let response = GetScrollbackResponse { + request_id, + success, + total_lines, + line_start, + line_end, + content, + has_more, + error, + }; + + handle_term_scrollback_response(response); + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d005cb2b..a2905c590 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod prompt_cmd; pub mod provider_pool_cmd; pub mod resilience_cmd; pub mod route_cmd; +pub mod screenshot_cmd; pub mod skill_cmd; pub mod switch_cmd; pub mod telemetry_cmd; diff --git a/src-tauri/src/commands/model_registry_cmd.rs b/src-tauri/src/commands/model_registry_cmd.rs index 62322a5ba..ed68c2639 100644 --- a/src-tauri/src/commands/model_registry_cmd.rs +++ b/src-tauri/src/commands/model_registry_cmd.rs @@ -169,3 +169,14 @@ pub async fn get_all_alias_configs( Ok(service.get_all_alias_configs().await) } + +/// 刷新模型注册表(强制从内嵌资源重新加载) +#[tauri::command] +pub async fn refresh_model_registry(state: State<'_, ModelRegistryState>) -> Result { + let guard = state.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + + service.force_reload().await +} diff --git a/src-tauri/src/commands/native_agent_cmd.rs b/src-tauri/src/commands/native_agent_cmd.rs index fbddea688..cbda98c30 100644 --- a/src-tauri/src/commands/native_agent_cmd.rs +++ b/src-tauri/src/commands/native_agent_cmd.rs @@ -160,14 +160,17 @@ pub async fn native_agent_chat_stream( model: Option, images: Option>, provider: Option, + terminal_mode: Option, ) -> Result<(), String> { + let terminal_mode = terminal_mode.unwrap_or(false); tracing::info!( - "[NativeAgent] 发送流式消息: message_len={}, model={:?}, provider={:?}, event={}, session={:?}", + "[NativeAgent] 发送流式消息: message_len={}, model={:?}, provider={:?}, event={}, session={:?}, terminal_mode={}", message.len(), model, provider, event_name, - session_id + session_id, + terminal_mode ); // 获取配置信息 @@ -241,7 +244,8 @@ pub async fn native_agent_chat_stream( } // 获取工具注册表(用于创建 ToolLoopEngine) - let tool_registry = agent_state.get_tool_registry()?; + // 如果是 terminal_mode,使用 TerminalTool 替代 BashTool + let tool_registry = agent_state.get_tool_registry_with_mode(terminal_mode)?; let request = NativeChatRequest { session_id, // 使用前端传递的 session_id 以保持上下文 diff --git a/src-tauri/src/commands/screenshot_cmd.rs b/src-tauri/src/commands/screenshot_cmd.rs new file mode 100644 index 000000000..ed340ec5a --- /dev/null +++ b/src-tauri/src/commands/screenshot_cmd.rs @@ -0,0 +1,352 @@ +//! 截图对话命令模块 +//! +//! 提供截图对话功能的 Tauri 命令接口,包括: +//! - 获取和保存实验室功能配置 +//! - 启动截图 +//! - 验证和更新快捷键 +//! - 读取图片为 Base64 +//! +//! _需求: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_ + +use crate::config::{ExperimentalFeatures, GlobalConfigManagerState}; +use crate::screenshot::{capture, shortcut}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use std::path::Path; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::fs; +use tracing::{debug, error, info}; + +/// 获取实验室功能配置 +/// +/// 从应用状态中获取当前的实验室功能配置 +/// +/// # 返回 +/// 成功返回 ExperimentalFeatures,失败返回错误信息 +/// +/// _需求: 1.1_ +#[tauri::command] +pub async fn get_experimental_config( + config_manager: State<'_, GlobalConfigManagerState>, +) -> Result { + debug!("获取实验室功能配置"); + + let config = config_manager.config(); + Ok(config.experimental.clone()) +} + +/// 保存实验室功能配置 +/// +/// 将实验室功能配置保存到应用状态和配置文件 +/// 同时根据配置状态注册或注销快捷键 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `config_manager`: 全局配置管理器状态 +/// - `experimental_config`: 要保存的实验室功能配置 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误信息 +/// +/// _需求: 1.4, 1.5_ +#[tauri::command] +pub async fn save_experimental_config( + app: AppHandle, + config_manager: State<'_, GlobalConfigManagerState>, + experimental_config: ExperimentalFeatures, +) -> Result<(), String> { + info!( + "保存实验室功能配置: enabled={}, shortcut={}", + experimental_config.screenshot_chat.enabled, experimental_config.screenshot_chat.shortcut + ); + + // 获取旧配置以比较变化 + let old_config = config_manager.config(); + let was_enabled = old_config.experimental.screenshot_chat.enabled; + let is_enabled = experimental_config.screenshot_chat.enabled; + + debug!( + "配置变化: was_enabled={}, is_enabled={}", + was_enabled, is_enabled + ); + + // 更新配置 + let mut new_config = old_config.clone(); + new_config.experimental = experimental_config.clone(); + + // 保存配置到文件 + debug!("开始保存配置到文件..."); + if let Err(e) = config_manager.save_config(&new_config).await { + error!("保存配置失败: {}", e); + return Err(format!("保存配置失败: {}", e)); + } + info!("配置文件保存成功"); + + // 根据功能开关状态注册或注销快捷键 + if was_enabled != is_enabled { + if is_enabled { + info!( + "截图对话功能已启用,注册快捷键: {}", + experimental_config.screenshot_chat.shortcut + ); + if let Err(e) = shortcut::register(&app, &experimental_config.screenshot_chat.shortcut) + { + error!("注册快捷键失败: {}", e); + return Err(format!("注册快捷键失败: {}", e)); + } + info!("快捷键注册成功"); + } else { + info!("截图对话功能已禁用,注销快捷键"); + if let Err(e) = shortcut::unregister(&app) { + error!("注销快捷键失败: {}", e); + return Err(format!("注销快捷键失败: {}", e)); + } + info!("快捷键注销成功"); + } + } + + info!("实验室功能配置保存完成"); + Ok(()) +} + +/// 启动截图 +/// +/// 启动交互式截图,返回截图文件路径 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回截图文件路径,用户取消返回空字符串,失败返回错误信息 +/// +/// _需求: 3.1_ +#[tauri::command] +pub async fn start_screenshot(app: AppHandle) -> Result { + info!("启动截图命令"); + + match capture::start_capture(&app).await { + Ok(path) => { + info!("截图成功: {:?}", path); + Ok(path.to_string_lossy().to_string()) + } + Err(capture::CaptureError::Cancelled) => { + info!("用户取消了截图"); + Ok(String::new()) + } + Err(e) => { + error!("截图失败: {}", e); + Err(format!("截图失败: {}", e)) + } + } +} + +/// 验证快捷键格式 +/// +/// 检查快捷键字符串是否符合 Tauri 快捷键格式要求 +/// +/// # 参数 +/// - `shortcut_str`: 快捷键字符串,如 "CommandOrControl+Shift+S" +/// +/// # 返回 +/// 如果格式有效返回 true,否则返回错误信息 +/// +/// _需求: 2.2_ +#[tauri::command] +pub fn validate_shortcut(shortcut_str: String) -> Result { + debug!("验证快捷键格式: {}", shortcut_str); + + match shortcut::validate(&shortcut_str) { + Ok(()) => Ok(true), + Err(e) => Err(format!("{}", e)), + } +} + +/// 更新截图快捷键 +/// +/// 原子性地更新快捷键:先注销旧快捷键,再注册新快捷键 +/// 同时更新配置文件 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `config_manager`: 全局配置管理器状态 +/// - `new_shortcut`: 新的快捷键字符串 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误信息 +/// +/// _需求: 2.4_ +#[tauri::command] +pub async fn update_screenshot_shortcut( + app: AppHandle, + config_manager: State<'_, GlobalConfigManagerState>, + new_shortcut: String, +) -> Result<(), String> { + info!("更新截图快捷键: {}", new_shortcut); + + // 验证新快捷键格式 + shortcut::validate(&new_shortcut).map_err(|e| format!("快捷键格式无效: {}", e))?; + + // 获取当前配置 + let mut config = config_manager.config(); + + // 检查功能是否启用 + if config.experimental.screenshot_chat.enabled { + // 更新快捷键(原子操作) + shortcut::update(&app, &new_shortcut).map_err(|e| format!("更新快捷键失败: {}", e))?; + } + + // 更新配置 + config.experimental.screenshot_chat.shortcut = new_shortcut; + + // 保存配置到文件 + config_manager + .save_config(&config) + .await + .map_err(|e| format!("保存配置失败: {}", e))?; + + info!("截图快捷键更新成功"); + Ok(()) +} + +/// 关闭截图对话窗口 +/// +/// 关闭当前打开的截图对话悬浮窗口 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误信息 +#[tauri::command] +pub fn close_screenshot_chat_window(app: AppHandle) -> Result<(), String> { + info!("关闭截图对话窗口"); + + crate::screenshot::window::close_floating_window(&app) + .map_err(|e| format!("关闭窗口失败: {}", e)) +} + +/// 读取图片文件并转换为 Base64 +/// +/// 读取指定路径的图片文件,并将其内容编码为 Base64 字符串 +/// +/// # 参数 +/// - `path`: 图片文件路径 +/// +/// # 返回 +/// 成功返回 Base64 编码的图片数据,失败返回错误信息 +/// +/// _需求: 5.1_ +#[tauri::command] +pub async fn read_image_as_base64(path: String) -> Result { + debug!("读取图片为 Base64: {}", path); + + let path = Path::new(&path); + + // 检查文件是否存在 + if !path.exists() { + return Err(format!("文件不存在: {}", path.display())); + } + + // 读取文件内容 + let bytes = fs::read(path) + .await + .map_err(|e| format!("读取文件失败: {}", e))?; + + // 检查文件是否为空 + if bytes.is_empty() { + return Err("文件为空".to_string()); + } + + // 编码为 Base64 + let base64 = STANDARD.encode(&bytes); + + debug!("图片读取成功,大小: {} 字节", bytes.len()); + Ok(base64) +} + +/// 截图对话消息结构 +#[derive(Debug, Clone, serde::Serialize)] +pub struct ScreenshotChatMessage { + pub message: String, + pub image_path: Option, + pub image_base64: Option, +} + +/// 发送截图对话消息到主应用 +/// +/// 将用户输入的消息和截图发送到主应用的 Agent 聊天界面 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `message`: 用户输入的消息 +/// - `image_path`: 截图文件路径(可选) +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误信息 +#[tauri::command] +pub async fn send_screenshot_chat( + app: AppHandle, + message: String, + image_path: Option, +) -> Result<(), String> { + info!( + "发送截图对话: message={}, image_path={:?}", + message, image_path + ); + + // 如果有图片,读取为 Base64 + let image_base64 = if let Some(ref path) = image_path { + match read_image_as_base64(path.clone()).await { + Ok(base64) => Some(base64), + Err(e) => { + error!("读取图片失败: {}", e); + None + } + } + } else { + None + }; + + // 构建消息 + let chat_message = ScreenshotChatMessage { + message, + image_path, + image_base64, + }; + + // 发送事件到主窗口 + if let Some(main_window) = app.get_webview_window("main") { + main_window + .emit("screenshot-chat-message", &chat_message) + .map_err(|e| format!("发送事件失败: {}", e))?; + + // 恢复并聚焦主窗口(主窗口在截图时被最小化) + let _ = main_window.unminimize(); + let _ = main_window.show(); + let _ = main_window.set_focus(); + } else { + // 尝试发送到所有窗口 + app.emit("screenshot-chat-message", &chat_message) + .map_err(|e| format!("发送事件失败: {}", e))?; + } + + info!("截图对话消息已发送"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_shortcut_valid() { + assert!(validate_shortcut("CommandOrControl+Shift+S".to_string()).is_ok()); + assert!(validate_shortcut("Alt+F4".to_string()).is_ok()); + assert!(validate_shortcut("Ctrl+C".to_string()).is_ok()); + } + + #[test] + fn test_validate_shortcut_invalid() { + assert!(validate_shortcut("".to_string()).is_err()); + assert!(validate_shortcut("InvalidKey".to_string()).is_err()); + } +} diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 249fcc9d9..e5bc32e7d 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -65,9 +65,11 @@ pub struct CreateWebviewRequest { pub url: String, /// 面板标题 pub title: Option, - /// X 坐标(相对于主窗口) + /// X 坐标(相对于主窗口)- 预留,当前使用居中显示 + #[allow(dead_code)] pub x: f64, - /// Y 坐标(相对于主窗口) + /// Y 坐标(相对于主窗口)- 预留,当前使用居中显示 + #[allow(dead_code)] pub y: f64, /// 宽度 pub width: f64, diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index b54b03c13..155e381d8 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -22,11 +22,12 @@ pub use import::{ImportOptions, ImportService, ValidationResult}; pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry, - CredentialPoolConfig, CustomProviderConfig, EndpointProvidersConfig, GeminiApiKeyEntry, - IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ModelInfo, - ModelsConfig, NativeAgentConfig, ProviderConfig, ProviderModelsConfig, ProvidersConfig, - QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, ServerConfig, - TlsConfig, VertexApiKeyEntry, VertexModelAlias, DEFAULT_API_KEY, + CredentialPoolConfig, CustomProviderConfig, EndpointProvidersConfig, ExperimentalFeatures, + GeminiApiKeyEntry, IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, + ModelInfo, ModelsConfig, NativeAgentConfig, ProviderConfig, ProviderModelsConfig, + ProvidersConfig, QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, + ScreenshotChatConfig, ServerConfig, TlsConfig, VertexApiKeyEntry, VertexModelAlias, + DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/src/config/observer/subject.rs b/src-tauri/src/config/observer/subject.rs index 7c1f95943..5162b7399 100644 --- a/src-tauri/src/config/observer/subject.rs +++ b/src-tauri/src/config/observer/subject.rs @@ -14,6 +14,8 @@ use tokio::sync::broadcast; /// Tauri 事件名称常量 pub const CONFIG_CHANGED_EVENT: &str = "config-changed"; +/// 预留:配置重新加载事件 +#[allow(dead_code)] pub const CONFIG_RELOAD_EVENT: &str = "config-reload"; /// 观察者条目 diff --git a/src-tauri/src/config/observer/traits.rs b/src-tauri/src/config/observer/traits.rs index a42600cc0..e3dd20bbe 100644 --- a/src-tauri/src/config/observer/traits.rs +++ b/src-tauri/src/config/observer/traits.rs @@ -95,6 +95,8 @@ impl ConfigObserver for SyncObserverWrapper } /// 函数式观察者(用于简单的回调场景) +/// 目前主要用于测试,将来可用于动态注册观察者 +#[allow(dead_code)] pub struct FnObserver where F: Fn(&ConfigChangeEvent, &Config) -> Result<(), String> + Send + Sync, @@ -108,6 +110,7 @@ impl FnObserver where F: Fn(&ConfigChangeEvent, &Config) -> Result<(), String> + Send + Sync, { + #[allow(dead_code)] pub fn new(name: impl Into, handler: F) -> Self { Self { name: name.into(), @@ -116,6 +119,7 @@ where } } + #[allow(dead_code)] pub fn with_priority(mut self, priority: i32) -> Self { self.priority = priority; self diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index e9ad0733f..f610ef275 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -188,6 +188,7 @@ fn arb_config() -> impl Strategy { models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), language: "zh".to_string(), + experimental: crate::config::ExperimentalFeatures::default(), }) } @@ -425,6 +426,7 @@ fn arb_valid_config() -> impl Strategy { models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), language: "zh".to_string(), + experimental: crate::config::ExperimentalFeatures::default(), }) } @@ -473,6 +475,7 @@ fn arb_invalid_config() -> impl Strategy { models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), language: "zh".to_string(), + experimental: crate::config::ExperimentalFeatures::default(), }; // 根据类型使配置无效 match invalid_type { diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index eb195f271..d0c73531b 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -320,6 +320,9 @@ pub struct Config { /// Native Agent 配置 #[serde(default)] pub agent: NativeAgentConfig, + /// 实验室功能配置 + #[serde(default)] + pub experimental: ExperimentalFeatures, } // ============ Native Agent 配置类型 ============ @@ -382,6 +385,44 @@ impl Default for NativeAgentConfig { } } +// ============ 实验室功能配置类型 ============ + +/// 截图对话功能配置 +/// +/// 配置截图对话功能的开关和快捷键 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ScreenshotChatConfig { + /// 是否启用截图对话功能 + #[serde(default)] + pub enabled: bool, + /// 触发截图的全局快捷键 + #[serde(default = "default_screenshot_shortcut")] + pub shortcut: String, +} + +fn default_screenshot_shortcut() -> String { + "CommandOrControl+Alt+Q".to_string() +} + +impl Default for ScreenshotChatConfig { + fn default() -> Self { + Self { + enabled: false, + shortcut: default_screenshot_shortcut(), + } + } +} + +/// 实验室功能配置 +/// +/// 管理所有实验性功能的开关和配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ExperimentalFeatures { + /// 截图对话功能配置 + #[serde(default)] + pub screenshot_chat: ScreenshotChatConfig, +} + impl NativeAgentConfig { /// 获取有效的系统提示词 /// @@ -1179,6 +1220,7 @@ impl Default for Config { language: default_language(), models: ModelsConfig::default(), agent: NativeAgentConfig::default(), + experimental: ExperimentalFeatures::default(), } } } @@ -1458,4 +1500,45 @@ mod unit_tests { let parsed: EndpointProvidersConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, config); } + + #[test] + fn test_screenshot_chat_config_default() { + let config = ScreenshotChatConfig::default(); + assert!(!config.enabled); + assert_eq!(config.shortcut, "CommandOrControl+Shift+S"); + } + + #[test] + fn test_experimental_features_default() { + let config = ExperimentalFeatures::default(); + assert!(!config.screenshot_chat.enabled); + assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Shift+S"); + } + + #[test] + fn test_experimental_features_serialization() { + let config = ExperimentalFeatures { + screenshot_chat: ScreenshotChatConfig { + enabled: true, + shortcut: "CommandOrControl+Alt+X".to_string(), + }, + }; + + let yaml = serde_yaml::to_string(&config).unwrap(); + assert!(yaml.contains("enabled: true")); + assert!(yaml.contains("shortcut: CommandOrControl+Alt+X")); + + let parsed: ExperimentalFeatures = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed, config); + } + + #[test] + fn test_config_with_experimental() { + let config = Config::default(); + assert!(!config.experimental.screenshot_chat.enabled); + assert_eq!( + config.experimental.screenshot_chat.shortcut, + "CommandOrControl+Shift+S" + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68760d463..20d51bb7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod processor; pub mod proxy; pub mod resilience; pub mod router; +pub mod screenshot; pub mod services; pub mod stream; pub mod streaming; diff --git a/src-tauri/src/models/model_registry.rs b/src-tauri/src/models/model_registry.rs index 770658ea2..ad5afa7f3 100644 --- a/src-tauri/src/models/model_registry.rs +++ b/src-tauri/src/models/model_registry.rs @@ -516,6 +516,8 @@ pub struct ModelsDevModalities { impl ModelsDevModel { /// 转换为 EnhancedModelMetadata + /// 预留:用于从 models.dev API 导入模型数据 + #[allow(dead_code)] pub fn to_enhanced_metadata( &self, provider_id: &str, @@ -584,6 +586,8 @@ impl ModelsDevModel { } /// 根据模型 ID 和名称推断服务等级 +/// 用于 to_enhanced_metadata 和测试 +#[allow(dead_code)] fn infer_model_tier(model_id: &str, model_name: &str) -> ModelTier { let id_lower = model_id.to_lowercase(); let name_lower = model_name.to_lowercase(); diff --git a/src-tauri/src/orchestrator/orchestrator.rs b/src-tauri/src/orchestrator/orchestrator.rs index 45d30760e..b93be3ca8 100644 --- a/src-tauri/src/orchestrator/orchestrator.rs +++ b/src-tauri/src/orchestrator/orchestrator.rs @@ -2,12 +2,12 @@ //! //! 统一的模型编排接口,整合模型池构建、策略选择和降级处理。 -use super::fallback::{FallbackHandler, FallbackPolicy, FallbackResult}; -use super::pool_builder::{CredentialInfo, DynamicPoolBuilder, ProviderType}; +use super::fallback::{FallbackHandler, FallbackPolicy}; +use super::pool_builder::{CredentialInfo, DynamicPoolBuilder}; use super::selector::{ModelSelector, SelectionResult}; use super::strategies::create_default_registry; -use super::strategy::{SelectionContext, StrategyError, StrategyInfo, StrategyResult, TaskHint}; -use super::tier::{AvailableModel, ServiceTier, TierConfig, TierPool}; +use super::strategy::{SelectionContext, StrategyInfo, StrategyResult, TaskHint}; +use super::tier::{AvailableModel, ServiceTier, TierPool}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; @@ -50,7 +50,8 @@ pub struct ModelOrchestrator { selector: ModelSelector, /// 模型池构建器 pool_builder: DynamicPoolBuilder, - /// 降级处理器 + /// 降级处理器(预留,将来实现自动降级功能) + #[allow(dead_code)] fallback_handler: FallbackHandler, /// 当前凭证列表 credentials: RwLock>, @@ -313,6 +314,7 @@ pub fn get_global_orchestrator() -> Option> { #[cfg(test)] mod tests { + use super::super::pool_builder::ProviderType; use super::*; #[tokio::test] diff --git a/src-tauri/src/processor/mod.rs b/src-tauri/src/processor/mod.rs index aa27520b4..8b37fe401 100644 --- a/src-tauri/src/processor/mod.rs +++ b/src-tauri/src/processor/mod.rs @@ -113,12 +113,11 @@ impl RequestProcessor { /// 注意:不再添加硬编码的路由规则,让用户设置的默认 Provider 生效 /// 用户可以通过 UI 或配置文件自定义路由规则 fn create_router_with_defaults() -> Router { - use crate::ProviderType; - // 创建空的路由器,默认 Provider 会在启动时从配置中设置 - let router = Router::new(ProviderType::Kiro); + // 不要硬编码任何 Provider,避免与用户配置冲突 + let router = Router::new_empty(); - tracing::info!("[ROUTER] 初始化路由器(无硬编码规则,使用用户配置的默认 Provider)"); + tracing::info!("[ROUTER] 初始化空路由器,等待从配置加载默认 Provider"); router } diff --git a/src-tauri/src/screenshot/README.md b/src-tauri/src/screenshot/README.md new file mode 100644 index 000000000..78bae5224 --- /dev/null +++ b/src-tauri/src/screenshot/README.md @@ -0,0 +1,69 @@ +# 截图对话模块 (screenshot/) + +截图对话功能的 Rust 后端实现,提供全局快捷键、屏幕截图和悬浮窗口管理。 + +## 模块结构 + +``` +screenshot/ +├── mod.rs # 模块入口,导出公共接口和初始化函数 +├── capture.rs # 屏幕截图服务,跨平台截图实现 +├── config.rs # 实验室功能配置管理 +├── shortcut.rs # 全局快捷键注册/注销/更新 +├── window.rs # 悬浮对话窗口管理 +└── README.md # 本文档 +``` + +## 核心功能 + +### 1. 模块初始化 (mod.rs) + +- `init(app)`: 初始化截图对话模块,根据配置注册快捷键 +- `cleanup(app)`: 清理模块资源,注销快捷键 + +### 2. 配置管理 (config.rs) + +- `load_experimental_config(app)`: 加载实验室功能配置 +- `is_screenshot_chat_enabled(config)`: 检查功能是否启用 +- `save_experimental_config(app, config)`: 保存配置到文件 + +### 3. 快捷键管理 (shortcut.rs) + +- `register(app, shortcut)`: 注册全局快捷键 +- `unregister(app)`: 注销当前快捷键 +- `update(app, new_shortcut)`: 原子性更新快捷键 +- `validate(shortcut)`: 验证快捷键格式 + +### 4. 截图服务 (capture.rs) + +- `start_capture(app)`: 启动交互式截图 +- 支持 macOS (screencapture)、Windows (PowerShell)、Linux (gnome-screenshot/scrot) + +### 5. 窗口管理 (window.rs) + +- `open_floating_window(app, image_path)`: 打开悬浮对话窗口 +- `close_floating_window(app)`: 关闭悬浮窗口 +- `is_floating_window_open(app)`: 检查窗口状态 + +## 依赖 + +- `tauri-plugin-global-shortcut`: 全局快捷键支持 +- `chrono`: 时间戳生成 +- `urlencoding`: URL 参数编码 + +## 配置示例 + +```yaml +experimental: + screenshot_chat: + enabled: true + shortcut: "CommandOrControl+Shift+S" +``` + +## 相关需求 + +- 需求 1: 实验室功能开关 +- 需求 2: 快捷键配置 +- 需求 3: 屏幕截图 +- 需求 4: 悬浮对话窗口 +- 需求 7: 模块化代码组织 diff --git a/src-tauri/src/screenshot/capture.rs b/src-tauri/src/screenshot/capture.rs new file mode 100644 index 000000000..c3c795394 --- /dev/null +++ b/src-tauri/src/screenshot/capture.rs @@ -0,0 +1,247 @@ +//! 屏幕截图服务 +//! +//! 提供跨平台的屏幕截图功能,支持交互式区域选择 + +use std::path::PathBuf; +use tauri::AppHandle; +use tracing::{debug, error, info, warn}; + +/// 截图错误类型 +#[derive(Debug, thiserror::Error)] +pub enum CaptureError { + #[error("用户取消截图")] + Cancelled, + #[error("权限不足: {0}")] + PermissionDenied(String), + #[error("系统错误: {0}")] + SystemError(String), + #[error("IO 错误: {0}")] + IoError(#[from] std::io::Error), + #[error("临时文件创建失败: {0}")] + TempFileError(String), +} + +/// 截图结果 +#[derive(Debug)] +pub enum CaptureResult { + /// 截图成功,包含图片路径 + Success(PathBuf), + /// 用户取消截图 + Cancelled, +} + +/// 启动交互式截图 +/// +/// 调用系统截图工具进行交互式区域选择 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回图片路径,用户取消返回 Cancelled 错误,其他情况返回相应错误 +/// +/// # 平台支持 +/// - macOS: 使用 `screencapture -i -x` 命令 +/// - Windows: 使用 Windows API 或系统截图工具 +/// - Linux: 使用 `gnome-screenshot` 或 `scrot` +pub async fn start_capture(_app: &AppHandle) -> Result { + info!("启动交互式截图"); + + // 生成临时文件路径 + let temp_dir = std::env::temp_dir(); + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f"); + let filename = format!("proxycast_screenshot_{}.png", timestamp); + let temp_path = temp_dir.join(&filename); + + debug!("截图临时文件路径: {:?}", temp_path); + + // 根据平台调用不同的截图命令 + #[cfg(target_os = "macos")] + { + capture_macos(&temp_path).await?; + } + + #[cfg(target_os = "windows")] + { + capture_windows(&temp_path).await?; + } + + #[cfg(target_os = "linux")] + { + capture_linux(&temp_path).await?; + } + + // 检查文件是否存在(用户可能取消了截图) + if !temp_path.exists() { + info!("截图文件不存在,用户可能取消了截图"); + return Err(CaptureError::Cancelled); + } + + // 检查文件大小(空文件表示取消) + let metadata = std::fs::metadata(&temp_path)?; + if metadata.len() == 0 { + info!("截图文件为空,用户取消了截图"); + std::fs::remove_file(&temp_path)?; + return Err(CaptureError::Cancelled); + } + + info!("截图成功: {:?}", temp_path); + Ok(temp_path) +} + +/// macOS 截图实现 +#[cfg(target_os = "macos")] +async fn capture_macos(output_path: &PathBuf) -> Result<(), CaptureError> { + use std::process::Command; + + debug!("使用 macOS screencapture 命令"); + + // 先检查屏幕录制权限 + // 通过尝试执行一个快速的全屏截图到 /dev/null 来检测权限 + let permission_check = Command::new("screencapture") + .args(["-x", "-c"]) // -c 截图到剪贴板,快速检测权限 + .output(); + + if let Ok(output) = permission_check { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("cannot") || stderr.contains("permission") { + warn!("屏幕录制权限不足: {}", stderr); + return Err(CaptureError::PermissionDenied( + "请在系统设置 → 隐私与安全性 → 录屏与系统录音 中授权 ProxyCast".to_string(), + )); + } + } + } + + // 使用 screencapture 命令 + // -i: 交互式选择区域 + // -x: 不播放截图声音 + let output = Command::new("screencapture") + .args(["-i", "-x", output_path.to_str().unwrap()]) + .output() + .map_err(|e| CaptureError::SystemError(format!("执行 screencapture 失败: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // 用户按 ESC 取消时,screencapture 返回非零状态但不创建文件 + if !output_path.exists() { + return Err(CaptureError::Cancelled); + } + error!("screencapture 命令失败: {}", stderr); + return Err(CaptureError::SystemError(format!( + "screencapture 失败: {}", + stderr + ))); + } + + Ok(()) +} + +/// Windows 截图实现 +#[cfg(target_os = "windows")] +async fn capture_windows(output_path: &PathBuf) -> Result<(), CaptureError> { + use std::process::Command; + + debug!("使用 Windows 截图工具"); + + // 使用 PowerShell 调用截图功能 + // 注意:这是一个简化实现,实际可能需要使用 Windows API + let script = format!( + r#" + Add-Type -AssemblyName System.Windows.Forms + $screen = [System.Windows.Forms.Screen]::PrimaryScreen + $bitmap = New-Object System.Drawing.Bitmap($screen.Bounds.Width, $screen.Bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($screen.Bounds.Location, [System.Drawing.Point]::Empty, $screen.Bounds.Size) + $bitmap.Save('{}') + "#, + output_path.to_str().unwrap().replace("\\", "\\\\") + ); + + let output = Command::new("powershell") + .args(["-Command", &script]) + .output() + .map_err(|e| CaptureError::SystemError(format!("执行 PowerShell 失败: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!("PowerShell 截图失败: {}", stderr); + return Err(CaptureError::SystemError(format!("截图失败: {}", stderr))); + } + + Ok(()) +} + +/// Linux 截图实现 +#[cfg(target_os = "linux")] +async fn capture_linux(output_path: &PathBuf) -> Result<(), CaptureError> { + use std::process::Command; + + debug!("使用 Linux 截图工具"); + + // 尝试使用 gnome-screenshot + let gnome_result = Command::new("gnome-screenshot") + .args(["-a", "-f", output_path.to_str().unwrap()]) + .output(); + + if let Ok(output) = gnome_result { + if output.status.success() { + return Ok(()); + } + } + + // 回退到 scrot + let scrot_result = Command::new("scrot") + .args(["-s", output_path.to_str().unwrap()]) + .output() + .map_err(|e| { + CaptureError::SystemError(format!( + "无法找到截图工具 (gnome-screenshot 或 scrot): {}", + e + )) + })?; + + if !scrot_result.status.success() { + let stderr = String::from_utf8_lossy(&scrot_result.stderr); + if !output_path.exists() { + return Err(CaptureError::Cancelled); + } + return Err(CaptureError::SystemError(format!("scrot 失败: {}", stderr))); + } + + Ok(()) +} + +/// 清理临时截图文件 +/// +/// # 参数 +/// - `path`: 要删除的文件路径 +pub fn cleanup_temp_file(path: &PathBuf) { + if path.exists() { + if let Err(e) = std::fs::remove_file(path) { + error!("删除临时截图文件失败: {}", e); + } else { + debug!("已删除临时截图文件: {:?}", path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_temp_path_generation() { + let temp_dir = std::env::temp_dir(); + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f"); + let filename = format!("proxycast_screenshot_{}.png", timestamp); + let temp_path = temp_dir.join(&filename); + + assert!(temp_path + .to_str() + .unwrap() + .contains("proxycast_screenshot_")); + assert!(temp_path.to_str().unwrap().ends_with(".png")); + } +} diff --git a/src-tauri/src/screenshot/config.rs b/src-tauri/src/screenshot/config.rs new file mode 100644 index 000000000..866ecc4a7 --- /dev/null +++ b/src-tauri/src/screenshot/config.rs @@ -0,0 +1,132 @@ +//! 截图对话配置管理 +//! +//! 提供实验室功能配置的加载和检查功能 + +use crate::config::{ExperimentalFeatures, GlobalConfigManagerState, ScreenshotChatConfig}; +use tauri::{AppHandle, Manager}; +use tracing::debug; + +/// 配置错误类型 +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("无法获取应用状态")] + StateNotFound, + #[error("配置加载失败: {0}")] + LoadFailed(String), +} + +/// 加载实验室功能配置 +/// +/// 从应用状态中获取当前的实验室功能配置 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 ExperimentalFeatures,失败返回错误 +pub fn load_experimental_config(app: &AppHandle) -> Result { + debug!("加载实验室功能配置"); + + // 获取全局配置管理器 + let config_manager = app + .try_state::() + .ok_or(ConfigError::StateNotFound)?; + + // 从配置中获取实验室功能配置 + let config = config_manager.config(); + let experimental = config.experimental.clone(); + + debug!( + "实验室功能配置: screenshot_chat.enabled={}, shortcut={}", + experimental.screenshot_chat.enabled, experimental.screenshot_chat.shortcut + ); + + Ok(experimental) +} + +/// 检查截图对话功能是否启用 +/// +/// # 参数 +/// - `config`: 实验室功能配置 +/// +/// # 返回 +/// 如果截图对话功能启用返回 true,否则返回 false +pub fn is_screenshot_chat_enabled(config: &ExperimentalFeatures) -> bool { + config.screenshot_chat.enabled +} + +/// 获取截图对话配置 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 ScreenshotChatConfig,失败返回错误 +pub fn get_screenshot_chat_config(app: &AppHandle) -> Result { + let experimental = load_experimental_config(app)?; + Ok(experimental.screenshot_chat) +} + +/// 保存实验室功能配置 +/// +/// 将实验室功能配置保存到应用状态和配置文件 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `experimental_config`: 要保存的实验室功能配置 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub async fn save_experimental_config( + app: &AppHandle, + experimental_config: ExperimentalFeatures, +) -> Result<(), ConfigError> { + debug!("保存实验室功能配置"); + + // 获取全局配置管理器 + let config_manager = app + .try_state::() + .ok_or(ConfigError::StateNotFound)?; + + // 获取当前配置并更新实验室功能部分 + let mut config = config_manager.config(); + config.experimental = experimental_config; + + // 保存配置 + config_manager + .save_config(&config) + .await + .map_err(|e| ConfigError::LoadFailed(e))?; + + debug!("实验室功能配置已保存"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_screenshot_chat_enabled_default() { + let config = ExperimentalFeatures::default(); + assert!(!is_screenshot_chat_enabled(&config)); + } + + #[test] + fn test_is_screenshot_chat_enabled_true() { + let config = ExperimentalFeatures { + screenshot_chat: ScreenshotChatConfig { + enabled: true, + shortcut: "CommandOrControl+Shift+S".to_string(), + }, + }; + assert!(is_screenshot_chat_enabled(&config)); + } + + #[test] + fn test_default_shortcut() { + let config = ScreenshotChatConfig::default(); + assert_eq!(config.shortcut, "CommandOrControl+Shift+S"); + assert!(!config.enabled); + } +} diff --git a/src-tauri/src/screenshot/mod.rs b/src-tauri/src/screenshot/mod.rs new file mode 100644 index 000000000..9e131064f --- /dev/null +++ b/src-tauri/src/screenshot/mod.rs @@ -0,0 +1,69 @@ +//! 截图对话模块 +//! +//! 提供截图对话功能的核心实现,包括: +//! - 屏幕截图服务 +//! - 全局快捷键管理 +//! - 悬浮窗口管理 +//! - 实验室功能配置 + +pub mod capture; +pub mod config; +pub mod shortcut; +pub mod window; + +use tauri::AppHandle; +use tracing::{error, info}; + +/// 截图模块错误类型 +#[derive(Debug, thiserror::Error)] +pub enum ScreenshotError { + #[error("配置错误: {0}")] + Config(String), + #[error("快捷键错误: {0}")] + Shortcut(#[from] shortcut::ShortcutError), + #[error("截图错误: {0}")] + Capture(#[from] capture::CaptureError), + #[error("窗口错误: {0}")] + Window(#[from] window::WindowError), +} + +/// 初始化截图对话模块 +/// +/// 根据配置决定是否注册全局快捷键 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn init(app: &AppHandle) -> Result<(), ScreenshotError> { + info!("初始化截图对话模块"); + + // 加载实验室功能配置 + let experimental_config = config::load_experimental_config(app) + .map_err(|e| ScreenshotError::Config(e.to_string()))?; + + // 检查截图对话功能是否启用 + if config::is_screenshot_chat_enabled(&experimental_config) { + info!("截图对话功能已启用,注册快捷键"); + shortcut::register(app, &experimental_config.screenshot_chat.shortcut)?; + } else { + info!("截图对话功能未启用,跳过快捷键注册"); + } + + Ok(()) +} + +/// 清理截图对话模块资源 +/// +/// 注销快捷键并清理临时文件 +pub fn cleanup(app: &AppHandle) -> Result<(), ScreenshotError> { + info!("清理截图对话模块资源"); + + // 尝试注销快捷键(忽略错误,因为可能未注册) + if let Err(e) = shortcut::unregister(app) { + error!("注销快捷键失败: {}", e); + } + + Ok(()) +} diff --git a/src-tauri/src/screenshot/shortcut.rs b/src-tauri/src/screenshot/shortcut.rs new file mode 100644 index 000000000..1efb21594 --- /dev/null +++ b/src-tauri/src/screenshot/shortcut.rs @@ -0,0 +1,322 @@ +//! 全局快捷键管理 +//! +//! 提供截图对话功能的全局快捷键注册、注销和更新功能 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use tauri::{AppHandle, Manager}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; +use tracing::{debug, error, info, warn}; + +use super::capture; +use super::window; + +/// 快捷键错误类型 +#[derive(Debug, thiserror::Error)] +pub enum ShortcutError { + #[error("快捷键格式无效: {0}")] + InvalidFormat(String), + #[error("快捷键注册失败: {0}")] + RegisterFailed(String), + #[error("快捷键注销失败: {0}")] + UnregisterFailed(String), + #[error("快捷键已被占用: {0}")] + AlreadyInUse(String), + #[error("快捷键解析失败: {0}")] + ParseFailed(String), +} + +/// 当前注册的快捷键(用于更新时注销旧快捷键) +static CURRENT_SHORTCUT: OnceLock>> = OnceLock::new(); + +/// 快捷键是否已注册 +static IS_REGISTERED: AtomicBool = AtomicBool::new(false); + +fn get_current_shortcut() -> &'static parking_lot::RwLock> { + CURRENT_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None)) +} + +/// 验证快捷键格式 +/// +/// 检查快捷键字符串是否符合 Tauri 快捷键格式要求 +/// +/// # 参数 +/// - `shortcut`: 快捷键字符串,如 "CommandOrControl+Shift+S" +/// +/// # 返回 +/// 如果格式有效返回 Ok(()), 否则返回错误 +/// +/// # 有效格式示例 +/// - "CommandOrControl+Shift+S" +/// - "Alt+F4" +/// - "Super+Space" +/// - "Ctrl+Alt+Delete" +pub fn validate(shortcut: &str) -> Result<(), ShortcutError> { + if shortcut.is_empty() { + return Err(ShortcutError::InvalidFormat("快捷键不能为空".to_string())); + } + + // 尝试解析快捷键以验证格式 + shortcut.parse::().map_err(|e| { + ShortcutError::InvalidFormat(format!("无法解析快捷键 '{}': {}", shortcut, e)) + })?; + + debug!("快捷键格式验证通过: {}", shortcut); + Ok(()) +} + +/// 注册全局快捷键 +/// +/// 注册截图对话功能的全局快捷键 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `shortcut_str`: 快捷键字符串 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn register(app: &AppHandle, shortcut_str: &str) -> Result<(), ShortcutError> { + info!("注册全局快捷键: {}", shortcut_str); + + // 验证快捷键格式 + validate(shortcut_str)?; + + // 解析快捷键 + let shortcut: Shortcut = shortcut_str + .parse() + .map_err(|e| ShortcutError::ParseFailed(format!("{}", e)))?; + + // 获取全局快捷键管理器 + let global_shortcut = app.global_shortcut(); + + // 检查快捷键是否已被注册 + let is_already_registered = global_shortcut.is_registered(shortcut.clone()); + info!( + "快捷键 {} 是否已注册: {}", + shortcut_str, is_already_registered + ); + + if is_already_registered { + warn!("快捷键已被注册: {}", shortcut_str); + // 如果是我们自己注册的,先注销 + if IS_REGISTERED.load(Ordering::SeqCst) { + info!("尝试注销已有的快捷键"); + if let Err(e) = global_shortcut.unregister(shortcut.clone()) { + error!("注销已有快捷键失败: {}", e); + } + } else { + return Err(ShortcutError::AlreadyInUse(shortcut_str.to_string())); + } + } + + // 克隆 app handle 用于回调 + let app_clone = app.clone(); + + // 注册快捷键 + info!("开始注册快捷键回调..."); + global_shortcut + .on_shortcut(shortcut.clone(), move |_app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + info!("截图快捷键被触发"); + handle_shortcut_triggered(&app_clone); + } + }) + .map_err(|e| { + error!("注册快捷键失败: {}", e); + ShortcutError::RegisterFailed(format!("{}", e)) + })?; + + // 更新状态 + IS_REGISTERED.store(true, Ordering::SeqCst); + *get_current_shortcut().write() = Some(shortcut_str.to_string()); + + info!("全局快捷键注册成功: {}", shortcut_str); + Ok(()) +} + +/// 注销全局快捷键 +/// +/// 注销当前注册的截图对话快捷键 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn unregister(app: &AppHandle) -> Result<(), ShortcutError> { + let current = get_current_shortcut().read().clone(); + + if let Some(shortcut_str) = current { + info!("注销全局快捷键: {}", shortcut_str); + + let shortcut: Shortcut = shortcut_str + .parse() + .map_err(|e| ShortcutError::ParseFailed(format!("{}", e)))?; + + let global_shortcut = app.global_shortcut(); + + if global_shortcut.is_registered(shortcut.clone()) { + global_shortcut + .unregister(shortcut) + .map_err(|e| ShortcutError::UnregisterFailed(format!("{}", e)))?; + } + + // 更新状态 + IS_REGISTERED.store(false, Ordering::SeqCst); + *get_current_shortcut().write() = None; + + info!("全局快捷键注销成功"); + } else { + debug!("没有已注册的快捷键需要注销"); + } + + Ok(()) +} + +/// 更新全局快捷键 +/// +/// 原子性地更新快捷键:先注销旧快捷键,再注册新快捷键 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `new_shortcut`: 新的快捷键字符串 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +/// +/// # 注意 +/// 此操作是原子性的:如果新快捷键注册失败,会尝试恢复旧快捷键 +pub fn update(app: &AppHandle, new_shortcut: &str) -> Result<(), ShortcutError> { + info!("更新全局快捷键: {}", new_shortcut); + + // 验证新快捷键格式 + validate(new_shortcut)?; + + // 保存旧快捷键以便恢复 + let old_shortcut = get_current_shortcut().read().clone(); + + // 注销旧快捷键 + if let Err(e) = unregister(app) { + warn!("注销旧快捷键失败: {}", e); + // 继续尝试注册新快捷键 + } + + // 注册新快捷键 + match register(app, new_shortcut) { + Ok(()) => { + info!("快捷键更新成功: {}", new_shortcut); + Ok(()) + } + Err(e) => { + error!("注册新快捷键失败: {}", e); + + // 尝试恢复旧快捷键 + if let Some(old) = old_shortcut { + warn!("尝试恢复旧快捷键: {}", old); + if let Err(restore_err) = register(app, &old) { + error!("恢复旧快捷键失败: {}", restore_err); + } + } + + Err(e) + } + } +} + +/// 检查快捷键是否已注册 +pub fn is_registered() -> bool { + IS_REGISTERED.load(Ordering::SeqCst) +} + +/// 获取当前注册的快捷键 +pub fn get_current() -> Option { + get_current_shortcut().read().clone() +} + +/// 处理快捷键触发事件 +/// +/// 当用户按下截图快捷键时调用此函数 +fn handle_shortcut_triggered(app: &AppHandle) { + info!("处理截图快捷键触发"); + + // 在后台线程中执行截图操作 + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + // 截图前最小化主窗口(而非隐藏),避免视觉干扰但保持可恢复 + let main_window = app_clone.get_webview_window("main"); + if let Some(win) = main_window.as_ref() { + debug!("最小化主窗口"); + let _ = win.minimize(); + } + + // 短暂延迟,确保窗口完全最小化 + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + match capture::start_capture(&app_clone).await { + Ok(image_path) => { + info!("截图成功: {:?}", image_path); + // 打开悬浮窗口 + if let Err(e) = window::open_floating_window(&app_clone, &image_path) { + error!("打开悬浮窗口失败: {}", e); + // 如果悬浮窗口打开失败,恢复主窗口 + if let Some(win) = main_window.as_ref() { + let _ = win.unminimize(); + let _ = win.set_focus(); + } + } + } + Err(capture::CaptureError::Cancelled) => { + info!("用户取消了截图"); + // 用户取消截图,恢复主窗口显示 + if let Some(win) = main_window.as_ref() { + debug!("恢复主窗口显示"); + let _ = win.unminimize(); + let _ = win.set_focus(); + } + } + Err(e) => { + error!("截图失败: {}", e); + // 截图失败,恢复主窗口显示 + if let Some(win) = main_window.as_ref() { + let _ = win.unminimize(); + let _ = win.set_focus(); + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_valid_shortcuts() { + // 有效的快捷键格式 + assert!(validate("CommandOrControl+Shift+S").is_ok()); + assert!(validate("Alt+F4").is_ok()); + assert!(validate("Ctrl+C").is_ok()); + assert!(validate("Super+Space").is_ok()); + assert!(validate("Shift+A").is_ok()); + } + + #[test] + fn test_validate_empty_shortcut() { + let result = validate(""); + assert!(result.is_err()); + match result { + Err(ShortcutError::InvalidFormat(msg)) => { + assert!(msg.contains("不能为空")); + } + _ => panic!("Expected InvalidFormat error"), + } + } + + #[test] + fn test_validate_invalid_shortcuts() { + // 无效的快捷键格式 + assert!(validate("InvalidKey").is_err()); + assert!(validate("+++").is_err()); + assert!(validate("Ctrl++").is_err()); + } +} diff --git a/src-tauri/src/screenshot/window.rs b/src-tauri/src/screenshot/window.rs new file mode 100644 index 000000000..da2f69051 --- /dev/null +++ b/src-tauri/src/screenshot/window.rs @@ -0,0 +1,300 @@ +//! 悬浮窗口管理 +//! +//! 提供截图对话悬浮窗口的创建、显示和关闭功能 + +use mouse_position::mouse_position::Mouse; +use std::path::Path; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +use tracing::{debug, info, warn}; + +#[cfg(target_os = "macos")] +use cocoa::appkit::{NSColor, NSWindow}; +#[cfg(target_os = "macos")] +use cocoa::base::{id, nil}; + +/// 窗口错误类型 +#[derive(Debug, thiserror::Error)] +pub enum WindowError { + #[error("窗口创建失败: {0}")] + CreateFailed(String), + #[error("窗口未找到: {0}")] + NotFound(String), + #[error("窗口操作失败: {0}")] + OperationFailed(String), +} + +/// 悬浮窗口标签 +const FLOATING_WINDOW_LABEL: &str = "screenshot-chat"; + +/// 窗口尺寸(包含 padding 用于阴影) +const WINDOW_WIDTH: f64 = 645.0; +const WINDOW_HEIGHT: f64 = 70.0; +/// 距离屏幕底部的距离 +const BOTTOM_MARGIN: f64 = 200.0; + +/// 获取鼠标所在的显示器 +/// +/// 使用 mouse_position crate 获取鼠标位置,然后遍历所有显示器找到鼠标所在的显示器 +fn get_monitor_at_cursor(app: &AppHandle) -> Option { + // 使用 mouse_position crate 获取鼠标位置 + let (cursor_x, cursor_y) = match Mouse::get_mouse_position() { + Mouse::Position { x, y } => { + debug!("鼠标位置 (mouse_position crate): ({}, {})", x, y); + (x as f64, y as f64) + } + Mouse::Error => { + debug!("无法获取鼠标位置"); + return None; + } + }; + + // 获取所有显示器 + let monitors = match app.available_monitors() { + Ok(monitors) => monitors, + Err(e) => { + debug!("无法获取显示器列表: {}", e); + return None; + } + }; + + // 查找鼠标所在的显示器 + for monitor in monitors { + let pos = monitor.position(); + let size = monitor.size(); + + let left = pos.x as f64; + let top = pos.y as f64; + let right = left + size.width as f64; + let bottom = top + size.height as f64; + + if cursor_x >= left && cursor_x < right && cursor_y >= top && cursor_y < bottom { + debug!( + "鼠标在显示器: {:?}, 位置: ({}, {}), 尺寸: {}x{}", + monitor.name(), + pos.x, + pos.y, + size.width, + size.height + ); + return Some(monitor); + } + } + + debug!( + "未找到鼠标所在的显示器,鼠标位置: ({}, {})", + cursor_x, cursor_y + ); + None +} + +/// 计算窗口位置(屏幕底部居中) +/// +/// 优先使用鼠标所在的显示器,否则使用主显示器 +/// 返回逻辑坐标(考虑 DPI 缩放) +fn calculate_window_position(app: &AppHandle) -> (f64, f64) { + // 优先获取鼠标所在的显示器 + let monitor = get_monitor_at_cursor(app).or_else(|| app.primary_monitor().ok().flatten()); + + if let Some(monitor) = monitor { + let screen_pos = monitor.position(); + let screen_size = monitor.size(); + let scale_factor = monitor.scale_factor(); + + // 物理像素转换为逻辑像素 + let screen_width = screen_size.width as f64 / scale_factor; + let screen_height = screen_size.height as f64 / scale_factor; + let screen_x = screen_pos.x as f64 / scale_factor; + let screen_y = screen_pos.y as f64 / scale_factor; + + // 在该显示器上水平居中(使用逻辑坐标) + let x = screen_x + (screen_width - WINDOW_WIDTH) / 2.0; + // 距离该显示器底部 BOTTOM_MARGIN(使用逻辑坐标) + let y = screen_y + screen_height - WINDOW_HEIGHT - BOTTOM_MARGIN; + + debug!( + "显示器: scale_factor={}, 物理位置: ({}, {}), 物理尺寸: {}x{}", + scale_factor, screen_pos.x, screen_pos.y, screen_size.width, screen_size.height + ); + debug!( + "逻辑坐标: 屏幕({}, {}), 尺寸: {}x{}, 窗口位置: ({}, {})", + screen_x, screen_y, screen_width, screen_height, x, y + ); + return (x, y); + } + + // 默认位置(如果无法获取屏幕尺寸) + debug!("无法获取显示器信息,使用默认位置"); + (400.0, 600.0) +} + +/// 打开悬浮对话窗口 +/// +/// 如果窗口已在 tauri.conf.json 中预定义,则显示并导航到新 URL +/// 否则动态创建一个全屏透明的悬浮窗口 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `image_path`: 截图文件路径 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn open_floating_window(app: &AppHandle, image_path: &Path) -> Result<(), WindowError> { + info!("打开悬浮对话窗口"); + + // 构建窗口 URL,包含图片路径参数 + let image_path_str = image_path.to_str().unwrap_or(""); + let encoded_path = urlencoding::encode(image_path_str); + let url = format!("/screenshot-chat?image={}", encoded_path); + + debug!("悬浮窗口 URL: {}", url); + + // 检查窗口是否已存在(可能是预定义的或之前创建的) + if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) { + info!("悬浮窗口已存在,导航到新 URL 并显示"); + + // 计算窗口位置(返回逻辑坐标) + let (x, y) = calculate_window_position(app); + + // 设置窗口位置(使用逻辑坐标) + use tauri::LogicalPosition; + let _ = window.set_position(LogicalPosition::new(x, y)); + + // macOS: 设置窗口和 webview 背景透明 + #[cfg(target_os = "macos")] + { + use objc::{msg_send, sel, sel_impl}; + if let Ok(ns_win) = window.ns_window() { + unsafe { + let ns_window = ns_win as id; + // 设置窗口背景透明 + let clear_color = NSColor::clearColor(nil); + ns_window.setBackgroundColor_(clear_color); + let _: () = msg_send![ns_window, setOpaque: false]; + // 禁用窗口阴影 + let _: () = msg_send![ns_window, setHasShadow: false]; + } + } + } + + // 使用 JavaScript 导航到新的 URL(更新图片路径) + let js = format!("window.location.href = '{}';", url); + window + .eval(&js) + .map_err(|e| WindowError::OperationFailed(format!("导航失败: {}", e)))?; + + // 显示窗口 + window + .show() + .map_err(|e| WindowError::OperationFailed(format!("显示窗口失败: {}", e)))?; + + // 聚焦窗口 + window + .set_focus() + .map_err(|e| WindowError::OperationFailed(format!("聚焦窗口失败: {}", e)))?; + + return Ok(()); + } + + // 窗口不存在,动态创建 + info!("动态创建悬浮窗口"); + + // 计算窗口位置 + let (x, y) = calculate_window_position(app); + + // 创建悬浮窗口(启用透明) + let window = WebviewWindowBuilder::new(app, FLOATING_WINDOW_LABEL, WebviewUrl::App(url.into())) + .inner_size(WINDOW_WIDTH, WINDOW_HEIGHT) + .position(x, y) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(true) + .focused(true) + .transparent(true) + .build() + .map_err(|e| WindowError::CreateFailed(format!("{}", e)))?; + + // macOS: 设置窗口和 webview 背景透明 + #[cfg(target_os = "macos")] + { + use objc::{msg_send, sel, sel_impl}; + if let Ok(ns_win) = window.ns_window() { + unsafe { + let ns_window = ns_win as id; + // 设置窗口背景透明 + let clear_color = NSColor::clearColor(nil); + ns_window.setBackgroundColor_(clear_color); + let _: () = msg_send![ns_window, setOpaque: false]; + // 禁用窗口阴影 + let _: () = msg_send![ns_window, setHasShadow: false]; + } + } + } + + info!("悬浮窗口创建成功: {}", FLOATING_WINDOW_LABEL); + + Ok(()) +} + +/// 关闭悬浮对话窗口 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn close_floating_window(app: &AppHandle) -> Result<(), WindowError> { + info!("关闭悬浮对话窗口"); + + if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) { + window + .close() + .map_err(|e| WindowError::OperationFailed(format!("关闭窗口失败: {}", e)))?; + info!("悬浮窗口已关闭"); + } else { + debug!("悬浮窗口不存在,无需关闭"); + } + + Ok(()) +} + +/// 检查悬浮窗口是否打开 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 如果窗口存在且可见返回 true,否则返回 false +pub fn is_floating_window_open(app: &AppHandle) -> bool { + app.get_webview_window(FLOATING_WINDOW_LABEL) + .map(|w| w.is_visible().unwrap_or(false)) + .unwrap_or(false) +} + +/// 聚焦悬浮窗口 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn focus_floating_window(app: &AppHandle) -> Result<(), WindowError> { + if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) { + window + .set_focus() + .map_err(|e| WindowError::OperationFailed(format!("聚焦窗口失败: {}", e)))?; + Ok(()) + } else { + Err(WindowError::NotFound(FLOATING_WINDOW_LABEL.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_window_label() { + assert_eq!(FLOATING_WINDOW_LABEL, "screenshot-chat"); + } +} diff --git a/src-tauri/src/server/handlers/api.rs b/src-tauri/src/server/handlers/api.rs index 682729543..3777f4a44 100644 --- a/src-tauri/src/server/handlers/api.rs +++ b/src-tauri/src/server/handlers/api.rs @@ -653,49 +653,23 @@ pub async fn chat_completions( ), ); - // 使用 RequestProcessor 解析模型别名和路由 - eprintln!("[CHAT_COMPLETIONS] 开始路由解析..."); - let provider = state.processor.resolve_and_route(&mut ctx).await; + // 使用 RequestProcessor 解析模型别名 + eprintln!("[CHAT_COMPLETIONS] 开始模型别名解析..."); + let resolved_model = state.processor.resolve_model(&request.model).await; + ctx.set_resolved_model(resolved_model.clone()); eprintln!( - "[CHAT_COMPLETIONS] 路由结果: provider={:?}, resolved_model={}", - provider, ctx.resolved_model + "[CHAT_COMPLETIONS] 模型别名解析结果: {} -> {}", + request.model, resolved_model ); - // 如果没有设置默认 Provider,返回错误 - let provider = match provider { - Some(p) => p, - None => { - eprintln!("[CHAT_COMPLETIONS] 未设置默认 Provider,返回错误"); - state.logs.write().await.add( - "error", - &format!("[ROUTE] request_id={} 未设置默认 Provider", ctx.request_id), - ); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": { - "message": "未设置默认 Provider,请先在设置中选择一个默认 Provider", - "type": "configuration_error", - "code": "no_default_provider" - } - })), - ) - .into_response(); - } - }; - // 更新请求中的模型名为解析后的模型 - if ctx.resolved_model != ctx.original_model { - request.model = ctx.resolved_model.clone(); - eprintln!( - "[CHAT_COMPLETIONS] 模型别名解析: {} -> {}", - ctx.original_model, ctx.resolved_model - ); + if resolved_model != request.model { + request.model = resolved_model.clone(); state.logs.write().await.add( "info", &format!( "[MAPPER] request_id={} alias={} -> model={}", - ctx.request_id, ctx.original_model, ctx.resolved_model + ctx.request_id, ctx.original_model, resolved_model ), ); } @@ -738,12 +712,12 @@ pub async fn chat_completions( ), ); - // 记录路由结果 + // 记录路由结果(使用 selected_provider) state.logs.write().await.add( "info", &format!( "[ROUTE] request_id={} model={} provider={}", - ctx.request_id, ctx.resolved_model, provider + ctx.request_id, ctx.resolved_model, selected_provider ), ); @@ -755,7 +729,7 @@ pub async fn chat_completions( // 尝试从凭证池中选择凭证 // 如果指定了 X-Provider-Id,优先使用它(不降级) - // 否则使用路由规则选择的 provider,如果找不到再回退到 selected_provider + // 否则使用 selected_provider eprintln!("[CHAT_COMPLETIONS] 开始选择凭证..."); let credential = match &state.db { Some(db) => { @@ -798,54 +772,30 @@ pub async fn chat_completions( } cred } else { - // 原有逻辑:使用路由规则选择的 provider - let provider_str = provider.to_string(); + // 使用 selected_provider(从 API Server 配置中获取) eprintln!( "[CHAT_COMPLETIONS] 尝试从凭证池选择: provider={}, model={}", - provider_str, request.model + selected_provider, request.model ); let cred = state .pool_service - .select_credential(db, &provider_str, Some(&request.model)) + .select_credential(db, &selected_provider, Some(&request.model)) .ok() .flatten(); if cred.is_some() { - eprintln!("[CHAT_COMPLETIONS] 找到凭证: provider={}", provider_str); - } else { - eprintln!("[CHAT_COMPLETIONS] 未找到凭证: provider={}", provider_str); - } - - // 如果路由规则的 provider 没有找到凭证,回退到 selected_provider - if cred.is_none() && provider_str != selected_provider { eprintln!( - "[CHAT_COMPLETIONS] 回退到 selected_provider: {}", + "[CHAT_COMPLETIONS] 找到凭证: provider={}", selected_provider ); - state.logs.write().await.add( - "debug", - &format!( - "[ROUTE] No credential found for routed provider '{}', trying selected_provider '{}'", - provider_str, selected_provider - ), - ); - let fallback_cred = state - .pool_service - .select_credential(db, &selected_provider, Some(&request.model)) - .ok() - .flatten(); - if fallback_cred.is_some() { - eprintln!( - "[CHAT_COMPLETIONS] 回退凭证找到: provider={}", - selected_provider - ); - } else { - eprintln!("[CHAT_COMPLETIONS] 回退凭证也未找到!"); - } - fallback_cred } else { - cred + eprintln!( + "[CHAT_COMPLETIONS] 未找到凭证: provider={}", + selected_provider + ); } + + cred } } None => { @@ -1020,8 +970,15 @@ pub async fn chat_completions( // 启动 Flow 捕获 let llm_request = build_llm_request_from_openai(&request, "/v1/chat/completions", &headers); + + // 尝试将 selected_provider 解析为 ProviderType + // 如果是自定义 provider ID,则使用 OpenAI 作为默认值(因为大多数自定义 provider 使用 OpenAI 协议) + let provider_type = selected_provider + .parse::() + .unwrap_or(ProviderType::OpenAI); + let flow_metadata = build_flow_metadata( - provider, + provider_type, Some(&cred.uuid), cred.name.as_deref(), &headers, @@ -1213,7 +1170,14 @@ pub async fn chat_completions( // 启动 Flow 捕获(legacy mode) let llm_request = build_llm_request_from_openai(&request, "/v1/chat/completions", &headers); - let flow_metadata = build_flow_metadata(provider, None, None, &headers, &ctx.request_id); + + // 尝试将 selected_provider 解析为 ProviderType + // 如果是自定义 provider ID,则使用 OpenAI 作为默认值 + let provider_type = selected_provider + .parse::() + .unwrap_or(ProviderType::OpenAI); + + let flow_metadata = build_flow_metadata(provider_type, None, None, &headers, &ctx.request_id); let flow_id = state .flow_monitor .start_flow(llm_request.clone(), flow_metadata.clone()) @@ -1744,38 +1708,18 @@ pub async fn anthropic_messages( ), ); - // 使用 RequestProcessor 解析模型别名和路由 - let provider = state.processor.resolve_and_route(&mut ctx).await; - - // 如果没有设置默认 Provider,返回错误 - let provider = match provider { - Some(p) => p, - None => { - state.logs.write().await.add( - "error", - &format!("[ROUTE] request_id={} 未设置默认 Provider", ctx.request_id), - ); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": { - "type": "configuration_error", - "message": "未设置默认 Provider,请先在设置中选择一个默认 Provider" - } - })), - ) - .into_response(); - } - }; + // 使用 RequestProcessor 解析模型别名 + let resolved_model = state.processor.resolve_model(&request.model).await; + ctx.set_resolved_model(resolved_model.clone()); // 更新请求中的模型名为解析后的模型 - if ctx.resolved_model != ctx.original_model { - request.model = ctx.resolved_model.clone(); + if resolved_model != request.model { + request.model = resolved_model.clone(); state.logs.write().await.add( "info", &format!( "[MAPPER] request_id={} alias={} -> model={}", - ctx.request_id, ctx.original_model, ctx.resolved_model + ctx.request_id, ctx.original_model, resolved_model ), ); } @@ -1840,12 +1784,12 @@ pub async fn anthropic_messages( ), ); - // 记录路由结果 + // 记录路由结果(使用 selected_provider) state.logs.write().await.add( "info", &format!( "[ROUTE] request_id={} model={} provider={}", - ctx.request_id, ctx.resolved_model, provider + ctx.request_id, ctx.resolved_model, selected_provider ), ); @@ -1857,14 +1801,13 @@ pub async fn anthropic_messages( // 尝试从凭证池中选择凭证 // 如果指定了 X-Provider-Id,优先使用它(不降级) - // 否则使用路由结果 provider(带智能降级) - let credential_provider = provider.to_string().to_lowercase(); + // 否则使用 selected_provider let credential = match &state.db { Some(db) => { // 如果指定了 X-Provider-Id,优先使用它(不降级) if let Some(ref explicit_provider_id) = provider_id_header { eprintln!( - "[AMP] 使用 X-Provider-Id 指定的 provider: {}", + "[ANTHROPIC_MESSAGES] 使用 X-Provider-Id 指定的 provider: {}", explicit_provider_id ); let cred = state @@ -1899,97 +1842,77 @@ pub async fn anthropic_messages( } cred } else { - // 原有逻辑:根据路由结果选择凭证(带智能降级) - state + // 使用 selected_provider(从 API Server 配置中获取) + eprintln!( + "[ANTHROPIC_MESSAGES] 尝试从凭证池选择: provider={}, model={}", + selected_provider, request.model + ); + let cred = state .pool_service - .select_credential_with_fallback( - db, - &state.api_key_service, - &credential_provider, - Some(&request.model), - None, // provider_id_hint 可从路由或请求头提取 - ) + .select_credential(db, &selected_provider, Some(&request.model)) .ok() - .flatten() + .flatten(); + + if cred.is_some() { + eprintln!( + "[ANTHROPIC_MESSAGES] 找到凭证: provider={}", + selected_provider + ); + } else { + eprintln!( + "[ANTHROPIC_MESSAGES] 未找到凭证: provider={}", + selected_provider + ); + } + + cred } } - None => None, - }; - - // 如果 Provider Pool 中没有找到凭证,尝试从 API Key Provider 获取 - let credential = if credential.is_none() { - // 根据路由结果映射到 ApiProviderType - use crate::database::dao::api_key_provider::ApiProviderType; - let api_provider_type = match credential_provider.as_str() { - "anthropic" | "claude" => Some(ApiProviderType::Anthropic), - "openai" => Some(ApiProviderType::Openai), - "gemini" => Some(ApiProviderType::Gemini), - _ => None, - }; - - if let (Some(db), Some(api_type)) = (&state.db, api_provider_type) { - // 使用按类型获取的方法(包括自定义 Provider) - match state.api_key_service.get_next_api_key_by_type(db, api_type) { - Ok(Some((_key_id, api_key, provider_info))) => { - state.logs.write().await.add( - "info", - &format!( - "[ROUTE] Using API Key Provider credential: provider={}, type={:?}, api_host={}", - provider_info.name, provider_info.provider_type, provider_info.api_host - ), - ); - - let base_url = if provider_info.api_host.is_empty() { - None - } else { - Some(provider_info.api_host.clone()) - }; - - let provider_type = match provider_info.provider_type { - ApiProviderType::Anthropic => crate::ProviderType::Anthropic, - ApiProviderType::Openai | ApiProviderType::OpenaiResponse => { - crate::ProviderType::OpenAI - } - ApiProviderType::Gemini => crate::ProviderType::GeminiApiKey, - _ => crate::ProviderType::OpenAI, - }; - - // 根据 provider_type 创建对应的 CredentialData - let credential_data = match provider_type { - crate::ProviderType::Anthropic => { - crate::models::provider_pool_model::CredentialData::AnthropicKey { - api_key: api_key.clone(), - base_url, - } - } - crate::ProviderType::GeminiApiKey => { - crate::models::provider_pool_model::CredentialData::GeminiApiKey { - api_key: api_key.clone(), - base_url, - excluded_models: vec![], - } - } - _ => crate::models::provider_pool_model::CredentialData::OpenAIKey { - api_key: api_key.clone(), - base_url, - }, - }; - - // 构建 ProviderCredential - let mut cred = crate::models::provider_pool_model::ProviderCredential::new( - provider_type, - credential_data, - ); - cred.name = Some(provider_info.name.clone()); - - Some(cred) - } - Ok(None) => None, - Err(_) => None, - } - } else { + None => { + eprintln!("[ANTHROPIC_MESSAGES] 数据库未初始化!"); None } + }; + + // 如果 Provider Pool 中没有找到凭证,尝试从 API Key Provider 获取(智能降级) + let credential = if credential.is_none() { + eprintln!("[ANTHROPIC_MESSAGES] Provider Pool 中未找到凭证,尝试 API Key Provider..."); + + // 策略 1: 优先按 provider_id 直接查找(支持自定义 Provider) + let mut found_credential: Option = + None; + + if let Some(db) = &state.db { + eprintln!( + "[ANTHROPIC_MESSAGES] 尝试按 provider_id '{}' 直接查找凭证", + selected_provider + ); + + match state.api_key_service.get_fallback_credential( + db, + &crate::models::provider_pool_model::PoolProviderType::Anthropic, + Some(&selected_provider), + ) { + Ok(Some(cred)) => { + eprintln!( + "[ANTHROPIC_MESSAGES] 通过 provider_id '{}' 找到凭证: name={:?}", + selected_provider, cred.name + ); + found_credential = Some(cred); + } + Ok(None) => { + eprintln!( + "[ANTHROPIC_MESSAGES] provider_id '{}' 未找到凭证", + selected_provider + ); + } + Err(e) => { + eprintln!("[ANTHROPIC_MESSAGES] 查找凭证时出错: {}", e); + } + } + } + + found_credential } else { credential }; @@ -2008,8 +1931,15 @@ pub async fn anthropic_messages( // 启动 Flow 捕获 let llm_request = build_llm_request_from_anthropic(&request, "/v1/messages", &headers); + + // 尝试将 selected_provider 解析为 ProviderType + // 如果是自定义 provider ID,则使用 OpenAI 作为默认值 + let provider_type = selected_provider + .parse::() + .unwrap_or(ProviderType::OpenAI); + let flow_metadata = build_flow_metadata( - provider, + provider_type, Some(&cred.uuid), cred.name.as_deref(), &headers, @@ -2198,7 +2128,14 @@ pub async fn anthropic_messages( // 启动 Flow 捕获(legacy mode) let llm_request = build_llm_request_from_anthropic(&request, "/v1/messages", &headers); - let flow_metadata = build_flow_metadata(provider, None, None, &headers, &ctx.request_id); + + // 尝试将 selected_provider 解析为 ProviderType + // 如果是自定义 provider ID,则使用 OpenAI 作为默认值 + let provider_type = selected_provider + .parse::() + .unwrap_or(ProviderType::OpenAI); + + let flow_metadata = build_flow_metadata(provider_type, None, None, &headers, &ctx.request_id); let flow_id = state .flow_monitor .start_flow(llm_request.clone(), flow_metadata.clone()) diff --git a/src-tauri/src/server/handlers/provider_calls.rs b/src-tauri/src/server/handlers/provider_calls.rs index e01b58530..ca7a30b2b 100644 --- a/src-tauri/src/server/handlers/provider_calls.rs +++ b/src-tauri/src/server/handlers/provider_calls.rs @@ -463,6 +463,9 @@ pub async fn call_provider_anthropic( if resp.status().is_success() { match resp.text().await { Ok(body) => { + // 记录原始响应以便调试 + eprintln!("[PROVIDER_CALL] OpenAI 响应: {}", &body[..body.len().min(500)]); + if let Ok(openai_resp) = serde_json::from_str::(&body) { @@ -491,7 +494,8 @@ pub async fn call_provider_anthropic( build_anthropic_response(&request.model, &parsed) } } else { - // 记录解析失败 + // 记录解析失败和原始响应 + eprintln!("[PROVIDER_CALL] 解析 OpenAI 响应失败,原始响应: {}", &body); if let Some(db) = &state.db { let _ = state.pool_service.mark_unhealthy( db, @@ -501,7 +505,7 @@ pub async fn call_provider_anthropic( } ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to parse OpenAI response"}})), + Json(serde_json::json!({"error": {"message": format!("Failed to parse OpenAI response. Body: {}", &body[..body.len().min(200)])}})), ) .into_response() } diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index a786a0153..fcc4d5668 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -315,13 +315,31 @@ impl ServerState { // 从配置初始化 Router 的默认 Provider { let default_provider_str = &config.routing.default_provider; - if let Ok(provider_type) = default_provider_str.parse::() { - let mut router = processor.router.write().await; - router.set_default_provider(provider_type); - tracing::info!( - "[SERVER] 从配置初始化 Router 默认 Provider: {}", - default_provider_str - ); + + // 尝试解析为 ProviderType 枚举 + match default_provider_str.parse::() { + Ok(provider_type) => { + let mut router = processor.router.write().await; + router.set_default_provider(provider_type); + tracing::info!( + "[SERVER] 从配置初始化 Router 默认 Provider: {} (ProviderType)", + default_provider_str + ); + } + Err(_) => { + // 如果解析失败,可能是自定义 provider ID + // 这种情况下,路由器保持空状态,请求会直接使用 provider_id 进行凭证查找 + tracing::warn!( + "[SERVER] 配置的默认 Provider '{}' 不是有效的 ProviderType 枚举值,可能是自定义 Provider ID。\ + 路由器将保持空状态,请求将直接使用 provider_id 进行凭证查找。", + default_provider_str + ); + eprintln!( + "[SERVER] 警告:默认 Provider '{}' 不是标准 Provider 类型(kiro/openai/claude等),\ + 可能是自定义 Provider ID。如果这是预期行为,请忽略此警告。", + default_provider_str + ); + } } } @@ -592,16 +610,29 @@ async fn update_processor_config(processor: &RequestProcessor, config: &Config) // 更新路由器默认 Provider { let mut router = processor.router.write().await; - if let Ok(provider_type) = config + + // 尝试解析为 ProviderType 枚举 + match config .routing .default_provider .parse::() { - router.set_default_provider(provider_type); - tracing::debug!( - "[HOT_RELOAD] 路由器默认 Provider 已更新: {}", - config.routing.default_provider - ); + Ok(provider_type) => { + router.set_default_provider(provider_type); + tracing::debug!( + "[HOT_RELOAD] 路由器默认 Provider 已更新: {} (ProviderType)", + config.routing.default_provider + ); + } + Err(_) => { + // 如果解析失败,可能是自定义 provider ID + // 清空路由器的默认 provider,让请求直接使用 provider_id + tracing::warn!( + "[HOT_RELOAD] 配置的默认 Provider '{}' 不是有效的 ProviderType 枚举值,可能是自定义 Provider ID。\ + 路由器默认 Provider 将被清空。", + config.routing.default_provider + ); + } } } @@ -729,13 +760,29 @@ async fn run_server( // 从配置初始化 Router 的默认 Provider if let Some(cfg) = &config { let default_provider_str = &cfg.routing.default_provider; - if let Ok(provider_type) = default_provider_str.parse::() { - let mut router = processor.router.write().await; - router.set_default_provider(provider_type); - tracing::info!( - "[SERVER] 从配置初始化 Router 默认 Provider: {}", - default_provider_str - ); + + // 尝试解析为 ProviderType 枚举 + match default_provider_str.parse::() { + Ok(provider_type) => { + let mut router = processor.router.write().await; + router.set_default_provider(provider_type); + tracing::info!( + "[SERVER] 从配置初始化 Router 默认 Provider: {} (ProviderType)", + default_provider_str + ); + } + Err(_) => { + // 如果解析失败,可能是自定义 provider ID + tracing::warn!( + "[SERVER] 配置的默认 Provider '{}' 不是有效的 ProviderType 枚举值,可能是自定义 Provider ID。\ + 路由器将保持空状态,请求将直接使用 provider_id 进行凭证查找。", + default_provider_str + ); + eprintln!( + "[SERVER] 警告:默认 Provider '{}' 不是标准 Provider 类型,可能是自定义 Provider ID", + default_provider_str + ); + } } } @@ -1496,7 +1543,7 @@ async fn amp_chat_completions( ), ); - // 尝试根据 provider 名称选择凭证(不降级,指定什么就用什么) + // 尝试根据 provider 名称选择凭证 eprintln!( "[AMP] 开始查找凭证: provider={}, model={}, db={}", provider, @@ -1506,11 +1553,11 @@ async fn amp_chat_completions( let credential = match &state.db { Some(db) => { eprintln!( - "[AMP] 使用 select_credential 查找凭证(不降级): provider={}", + "[AMP] 使用 select_credential 查找凭证(Provider Pool): provider={}", provider ); - // 使用 select_credential 而不是 select_credential_with_fallback,禁止降级 - if let Ok(Some(cred)) = + // 先尝试从 Provider Pool 查找 + let pool_cred = if let Ok(Some(cred)) = state .pool_service .select_credential(db, &provider, Some(&request.model)) @@ -1528,11 +1575,39 @@ async fn amp_chat_completions( eprintln!("[AMP] get_by_uuid 找到凭证: {:?}", cred.name); Some(cred) } else { + None + }; + + // 如果 Provider Pool 中没有找到,尝试从 API Key Provider 查找 + if pool_cred.is_none() { eprintln!( - "[AMP] 未找到任何凭证 for provider '{}',不进行降级", + "[AMP] Provider Pool 中未找到凭证,尝试 API Key Provider: provider={}", provider ); - None + + match state.api_key_service.get_fallback_credential( + db, + &crate::models::provider_pool_model::PoolProviderType::OpenAI, + Some(&provider), + ) { + Ok(Some(cred)) => { + eprintln!( + "[AMP] 通过 provider_id '{}' 找到 API Key Provider 凭证: name={:?}", + provider, cred.name + ); + Some(cred) + } + Ok(None) => { + eprintln!("[AMP] 未找到任何凭证 for provider '{}'", provider); + None + } + Err(e) => { + eprintln!("[AMP] 查找 API Key Provider 凭证时出错: {}", e); + None + } + } + } else { + pool_cred } } None => { @@ -1623,11 +1698,11 @@ async fn amp_messages( ), ); - // 尝试根据 provider 名称选择凭证(不降级,指定什么就用什么) + // 尝试根据 provider 名称选择凭证 let credential = match &state.db { Some(db) => { - // 使用 select_credential 而不是 select_credential_with_fallback,禁止降级 - if let Ok(Some(cred)) = + // 先尝试从 Provider Pool 查找 + let pool_cred = if let Ok(Some(cred)) = state .pool_service .select_credential(db, &provider, Some(&request.model)) @@ -1643,6 +1718,38 @@ async fn amp_messages( Some(cred) } else { None + }; + + // 如果 Provider Pool 中没有找到,尝试从 API Key Provider 查找 + if pool_cred.is_none() { + eprintln!( + "[AMP_MESSAGES] Provider Pool 中未找到凭证,尝试 API Key Provider: provider={}", + provider + ); + + match state.api_key_service.get_fallback_credential( + db, + &crate::models::provider_pool_model::PoolProviderType::Anthropic, + Some(&provider), + ) { + Ok(Some(cred)) => { + eprintln!( + "[AMP_MESSAGES] 通过 provider_id '{}' 找到 API Key Provider 凭证: name={:?}", + provider, cred.name + ); + Some(cred) + } + Ok(None) => { + eprintln!("[AMP_MESSAGES] 未找到任何凭证 for provider '{}'", provider); + None + } + Err(e) => { + eprintln!("[AMP_MESSAGES] 查找 API Key Provider 凭证时出错: {}", e); + None + } + } + } else { + pool_cred } } None => None, @@ -1887,6 +1994,8 @@ async fn amp_management_proxy_internal( } /// 内部 Anthropic messages 处理 (使用默认 Kiro) +/// 预留:用于内部直接调用 Kiro API +#[allow(dead_code)] async fn anthropic_messages_internal( state: &AppState, request: &AnthropicMessagesRequest, @@ -1954,6 +2063,8 @@ async fn anthropic_messages_internal( } /// 内部 OpenAI chat completions 处理 (使用默认 Kiro) +/// 预留:用于内部直接调用 Kiro API +#[allow(dead_code)] async fn chat_completions_internal(state: &AppState, request: &ChatCompletionRequest) -> Response { { let _guard = state.kiro_refresh_lock.lock().await; diff --git a/src-tauri/src/services/model_registry_service.rs b/src-tauri/src/services/model_registry_service.rs index 58483399d..071292364 100644 --- a/src-tauri/src/services/model_registry_service.rs +++ b/src-tauri/src/services/model_registry_service.rs @@ -122,59 +122,37 @@ impl ModelRegistryService { pub async fn initialize(&self) -> Result<(), String> { tracing::info!("[ModelRegistry] 初始化模型注册服务"); - // 1. 首先尝试从内嵌资源加载 - match self.load_from_embedded_resources().await { - Ok((models, aliases)) => { - tracing::info!( - "[ModelRegistry] 从内嵌资源加载了 {} 个模型, {} 个别名配置", - models.len(), - aliases.len() - ); + // 始终从内嵌资源加载,不再回退到数据库 + let (models, aliases) = self.load_from_embedded_resources().await?; - // 更新缓存 - { - let mut cache = self.models_cache.write().await; - *cache = models.clone(); - } - { - let mut cache = self.aliases_cache.write().await; - *cache = aliases; - } + tracing::info!( + "[ModelRegistry] 从内嵌资源加载了 {} 个模型, {} 个别名配置", + models.len(), + aliases.len() + ); - // 更新同步状态 - { - let mut state = self.sync_state.write().await; - state.model_count = models.len() as u32; - state.last_sync_at = Some(chrono::Utc::now().timestamp()); - state.is_syncing = false; - state.last_error = None; - } - - // 保存到数据库 - if let Err(e) = self.save_models_to_db(&models).await { - tracing::warn!("[ModelRegistry] 保存模型到数据库失败: {}", e); - } - - return Ok(()); - } - Err(e) => { - tracing::warn!("[ModelRegistry] 从内嵌资源加载失败: {}", e); - } + // 更新缓存 + { + let mut cache = self.models_cache.write().await; + *cache = models.clone(); + } + { + let mut cache = self.aliases_cache.write().await; + *cache = aliases; } - // 2. 回退到从数据库加载 - match self.load_from_db().await { - Ok(models) if !models.is_empty() => { - tracing::info!("[ModelRegistry] 从数据库加载了 {} 个模型", models.len()); - let mut cache = self.models_cache.write().await; - *cache = models; - } - Ok(_) => { - tracing::warn!("[ModelRegistry] 数据库中没有模型数据"); - } - Err(e) => { - tracing::error!("[ModelRegistry] 从数据库加载失败: {}", e); - } + // 更新同步状态 + { + let mut state = self.sync_state.write().await; + state.model_count = models.len() as u32; + state.last_sync_at = Some(chrono::Utc::now().timestamp()); + state.is_syncing = false; + state.last_error = None; + } + + // 保存到数据库(仅用于持久化,不影响运行时数据) + if let Err(e) = self.save_models_to_db(&models).await { + tracing::warn!("[ModelRegistry] 保存模型到数据库失败: {}", e); } Ok(()) @@ -195,9 +173,18 @@ impl ModelRegistryService { .as_ref() .ok_or_else(|| "资源目录未设置".to_string())?; + tracing::info!("[ModelRegistry] resource_dir: {:?}", resource_dir); + let models_dir = resource_dir.join(MODELS_RESOURCE_DIR); let index_file = models_dir.join("index.json"); + tracing::info!("[ModelRegistry] models_dir: {:?}", models_dir); + tracing::info!( + "[ModelRegistry] index_file: {:?}, exists: {}", + index_file, + index_file.exists() + ); + if !index_file.exists() { return Err(format!("索引文件不存在: {:?}", index_file)); } @@ -218,8 +205,11 @@ impl ModelRegistryService { let now = chrono::Utc::now().timestamp(); let providers_dir = models_dir.join("providers"); + tracing::info!("[ModelRegistry] providers_dir: {:?}", providers_dir); + for provider_id in &index.providers { let provider_file = providers_dir.join(format!("{}.json", provider_id)); + if !provider_file.exists() { tracing::warn!("[ModelRegistry] Provider 文件不存在: {:?}", provider_file); continue; @@ -248,10 +238,36 @@ impl ModelRegistryService { } } - // 去重:使用 HashMap 按 id 去重,保留第一个出现的模型 - let mut seen_ids = std::collections::HashSet::new(); + // 去重:优先保留 provider_id 为 "anthropic" 的模型 + // 对于相同 ID 的模型,anthropic 官方的优先级最高 + let mut seen_ids: std::collections::HashMap = + std::collections::HashMap::new(); let original_count = models.len(); - models.retain(|m| seen_ids.insert(m.id.clone())); + + let mut to_keep = vec![true; models.len()]; + for (idx, model) in models.iter().enumerate() { + if let Some(&existing_idx) = seen_ids.get(&model.id) { + // 已经有相同 ID 的模型 + let existing_model = &models[existing_idx]; + + // 如果当前模型是 anthropic 官方的,替换之前的 + if model.provider_id == "anthropic" && existing_model.provider_id != "anthropic" { + to_keep[existing_idx] = false; + seen_ids.insert(model.id.clone(), idx); + } else { + // 否则保留第一个 + to_keep[idx] = false; + } + } else { + seen_ids.insert(model.id.clone(), idx); + } + } + + models = models + .into_iter() + .enumerate() + .filter_map(|(idx, model)| if to_keep[idx] { Some(model) } else { None }) + .collect(); if models.len() < original_count { tracing::warn!( @@ -357,7 +373,8 @@ impl ModelRegistryService { } } - /// 从数据库加载模型 + /// 从数据库加载模型(预留,将来实现从数据库加载自定义模型) + #[allow(dead_code)] async fn load_from_db(&self) -> Result, String> { let (models, sync_rows) = { let conn = self.db.lock().map_err(|e| e.to_string())?; @@ -511,6 +528,47 @@ impl ModelRegistryService { self.sync_state.read().await.clone() } + /// 强制从内嵌资源重新加载模型数据 + /// + /// 清除数据库缓存并重新从资源文件加载最新的模型数据 + pub async fn force_reload(&self) -> Result { + tracing::info!("[ModelRegistry] 强制重新加载模型数据"); + + // 从内嵌资源加载 + let (models, aliases) = self.load_from_embedded_resources().await?; + + let model_count = models.len() as u32; + tracing::info!( + "[ModelRegistry] 从内嵌资源加载了 {} 个模型, {} 个别名配置", + models.len(), + aliases.len() + ); + + // 更新缓存 + { + let mut cache = self.models_cache.write().await; + *cache = models.clone(); + } + { + let mut cache = self.aliases_cache.write().await; + *cache = aliases; + } + + // 更新同步状态 + { + let mut state = self.sync_state.write().await; + state.model_count = model_count; + state.last_sync_at = Some(chrono::Utc::now().timestamp()); + state.is_syncing = false; + state.last_error = None; + } + + // 保存到数据库 + self.save_models_to_db(&models).await?; + + Ok(model_count) + } + /// 按 Provider 获取模型 pub async fn get_models_by_provider(&self, provider_id: &str) -> Vec { self.models_cache diff --git a/src-tauri/src/stream/generators/openai_sse.rs b/src-tauri/src/stream/generators/openai_sse.rs index c9d278a3f..f7e049b0a 100644 --- a/src-tauri/src/stream/generators/openai_sse.rs +++ b/src-tauri/src/stream/generators/openai_sse.rs @@ -11,7 +11,7 @@ //! data: [DONE] //! ``` -use crate::stream::events::{ContentBlockType, StopReason, StreamEvent}; +use crate::stream::events::{ContentBlockType, StreamEvent}; use serde::Serialize; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; @@ -346,6 +346,7 @@ struct OpenAiFunctionDelta<'a> { #[cfg(test)] mod tests { use super::*; + use crate::stream::events::StopReason; #[test] fn test_generate_text_delta() { diff --git a/src-tauri/src/terminal/integration/osc_parser.rs b/src-tauri/src/terminal/integration/osc_parser.rs index ae3a83351..58dd3686c 100644 --- a/src-tauri/src/terminal/integration/osc_parser.rs +++ b/src-tauri/src/terminal/integration/osc_parser.rs @@ -27,6 +27,8 @@ const OSC_START: &[u8] = b"\x1b]"; /// OSC 序列结束标记 - BEL const OSC_END_BEL: u8 = 0x07; /// OSC 序列结束标记 - ST (String Terminator) +/// 预留:某些终端使用 ST 而不是 BEL 作为结束标记 +#[allow(dead_code)] const OSC_END_ST: &[u8] = b"\x1b\\"; /// OSC 序列类型 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index aaf42e43e..c2e5a9a63 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.36.3", + "version": "0.37.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", @@ -21,11 +21,28 @@ "resizable": true, "fullscreen": false, "center": true + }, + { + "label": "screenshot-chat", + "title": "", + "url": "/screenshot-chat", + "width": 645, + "height": 70, + "resizable": false, + "decorations": false, + "alwaysOnTop": true, + "center": true, + "visible": false, + "focus": true, + "skipTaskbar": true, + "transparent": true, + "shadow": false } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'" - } + "csp": "default-src 'self' tauri:; img-src 'self' asset: tauri: data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' tauri: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'" + }, + "macOSPrivateApi": true }, "bundle": { "active": true, diff --git a/src/App.tsx b/src/App.tsx index 35f36043c..ed959efec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -137,8 +137,8 @@ function AppContent() { if (currentPage.startsWith("plugin:")) { const pluginId = currentPage.slice(7); // 移除 "plugin:" 前缀 - // 需要全屏显示的插件列表 - const fullscreenPlugins = ["terminal-plugin"]; + // 需要全屏显示的插件列表(终端已内置,不再需要插件) + const fullscreenPlugins: string[] = []; const isFullscreen = fullscreenPlugins.includes(pluginId); if (isFullscreen) { diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx new file mode 100644 index 000000000..4391e89fb --- /dev/null +++ b/src/RootRouter.tsx @@ -0,0 +1,31 @@ +/** + * @file RootRouter.tsx + * @description 根路由组件 - 根据 URL 路径渲染对应的组件 + */ + +import App from "./App"; +import { ScreenshotChatPage } from "./pages/screenshot-chat"; +import { Toaster } from "./components/ui/sonner"; + +/** + * 根据 URL 路径渲染对应的组件 + * + * - /screenshot-chat: 截图对话悬浮窗口(独立 Tauri 窗口) + * - 其他: 主应用 + */ +export function RootRouter() { + const pathname = window.location.pathname; + + // 截图对话悬浮窗口路由 + if (pathname === "/screenshot-chat") { + return ; + } + + // 默认渲染主应用 + return ( + <> + + + + ); +} diff --git a/src/components/README.md b/src/components/README.md index 1d65bfa3e..bc9d6e55f 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -22,6 +22,7 @@ React 组件层,包含 UI 组件和业务组件。 - `provider-pool/` - Provider 凭证池管理组件 - `resilience/` - 弹性策略配置组件 - `routing/` - 路由规则配置组件 +- `screenshot-chat/` - 截图对话功能组件(实验室功能) - `settings/` - 设置页面组件 - `skills/` - 技能管理组件 - `switch/` - 开关控制组件 diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 58bf77ad7..892052c19 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -213,46 +213,35 @@ export const ChatNavbar: React.FC = ({ // 从 model_registry 获取模型 // 优先使用 registryId,如果没有模型则回退到 fallbackRegistryId - let models = registryModels - .filter((m) => m.provider_id === selectedProvider.registryId) - .map((m) => m.id); + let models = registryModels.filter( + (m) => m.provider_id === selectedProvider.registryId, + ); // 如果没有找到模型,尝试使用 fallbackRegistryId if (models.length === 0 && selectedProvider.fallbackRegistryId) { - models = registryModels - .filter((m) => m.provider_id === selectedProvider.fallbackRegistryId) - .map((m) => m.id); + models = registryModels.filter( + (m) => m.provider_id === selectedProvider.fallbackRegistryId, + ); } - // 按照模型名称排序,优先显示最新版本 - // 排序规则: - // 1. 带日期后缀的模型(如 claude-opus-4-5-20251101)按日期降序 - // 2. 带 "latest" 后缀的模型排在最前面 - // 3. 其他模型按字母顺序 - return models.sort((a, b) => { - const aIsLatest = a.includes("-latest"); - const bIsLatest = b.includes("-latest"); + // 按照 release_date 和 is_latest 排序 + const sortedModels = [...models].sort((a, b) => { + // 1. is_latest 优先 + if (a.is_latest && !b.is_latest) return -1; + if (!a.is_latest && b.is_latest) return 1; - // latest 版本排在最前面 - if (aIsLatest && !bIsLatest) return -1; - if (!aIsLatest && bIsLatest) return 1; - - // 提取日期后缀(如 20251101) - const dateRegex = /-(\d{8})$/; - const aMatch = a.match(dateRegex); - const bMatch = b.match(dateRegex); - - if (aMatch && bMatch) { - // 两个都有日期,按日期降序(最新的在前) - return bMatch[1].localeCompare(aMatch[1]); + // 2. 按 release_date 降序(最新的在前) + if (a.release_date && b.release_date) { + return b.release_date.localeCompare(a.release_date); } + if (a.release_date && !b.release_date) return -1; + if (!a.release_date && b.release_date) return 1; - if (aMatch && !bMatch) return -1; // 有日期的排在前面 - if (!aMatch && bMatch) return 1; - - // 其他情况按字母降序(通常版本号大的在前) - return b.localeCompare(a); + // 3. 按 display_name 字母序 + return a.display_name.localeCompare(b.display_name); }); + + return sortedModels.map((m) => m.id); }, [selectedProvider, registryModels, aliasConfig]); // 初始化:优先选择服务器默认 Provider,否则选择第一个已配置的 diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index d8aa79708..e5ce2d2cb 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -197,6 +197,43 @@ export function useAgentChat() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 监听截图对话消息事件 + useEffect(() => { + let unlisten: UnlistenFn | null = null; + + const setupListener = async () => { + unlisten = await listen<{ + message: string; + image_path: string | null; + image_base64: string | null; + }>("screenshot-chat-message", async (event) => { + console.log("[AgentChat] 收到截图对话消息:", event.payload); + const { message, image_base64 } = event.payload; + + // 构建图片数组 + const images: MessageImage[] = []; + if (image_base64) { + images.push({ + data: image_base64, + mediaType: "image/png", + }); + } + + // 发送消息 + await sendMessage(message, images, false, false); + }); + }; + + setupListener(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [providerType, model, sessionId]); + // 当 sessionId 变化时刷新话题列表 useEffect(() => { if (sessionId) { diff --git a/src/components/plugins/PluginUIRenderer.tsx b/src/components/plugins/PluginUIRenderer.tsx index 52d5daae0..78fb67bd8 100644 --- a/src/components/plugins/PluginUIRenderer.tsx +++ b/src/components/plugins/PluginUIRenderer.tsx @@ -13,7 +13,6 @@ import { invoke } from "@tauri-apps/api/core"; import { BrowserInterceptorTool } from "@/components/tools/browser-interceptor/BrowserInterceptorTool"; import { FlowMonitorPage } from "@/pages"; import { ConfigManagementPage } from "@/components/config/ConfigManagementPage"; -import { TerminalPage } from "@/components/terminal"; import { PluginUIRenderer as DynamicPluginRenderer } from "@/lib/plugin-loader/PluginUIRenderer"; import { usePluginSDK } from "@/lib/plugin-sdk"; import { Button } from "@/components/ui/button"; @@ -169,6 +168,7 @@ function PluginLauncher({ /** * 内置插件组件映射 * 注意: machine-id-tool 已移除,改为从插件包动态加载 + * 注意: terminal-plugin 已移除,终端功能已内置到应用中 */ const builtinPluginComponents: Record< string, @@ -177,7 +177,6 @@ const builtinPluginComponents: Record< "browser-interception": BrowserInterceptorTool, "flow-monitor": FlowMonitorPage, "config-switch": ConfigManagementPage, - "terminal-plugin": TerminalPage, }; /** diff --git a/src/components/screenshot-chat/ChatInput.tsx b/src/components/screenshot-chat/ChatInput.tsx new file mode 100644 index 000000000..46b81ce48 --- /dev/null +++ b/src/components/screenshot-chat/ChatInput.tsx @@ -0,0 +1,107 @@ +/** + * @file ChatInput.tsx + * @description 聊天输入框组件,支持文本输入和发送 + * @module components/screenshot-chat/ChatInput + */ + +import React, { useCallback, useRef, useEffect } from "react"; +import type { ChatInputProps } from "./types"; + +/** + * 聊天输入框组件 + * + * 提供文本输入框和发送按钮,支持 Enter 键发送 + * + * 需求: + * - 4.3: 悬浮窗口应提供文本输入框供用户输入问题 + * - 4.4: 当用户按下 Enter 或点击发送时,悬浮窗口应将图片和文本发送给 AI + */ +export const ChatInput: React.FC = ({ + value, + onChange, + onSend, + disabled = false, + isLoading = false, + placeholder = "输入问题...", +}) => { + const inputRef = useRef(null); + + // 自动聚焦输入框 + useEffect(() => { + if (!disabled && inputRef.current) { + inputRef.current.focus(); + } + }, [disabled]); + + // 处理键盘事件 + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !disabled && !isLoading) { + e.preventDefault(); + if (value.trim()) { + onSend(); + } + } + }, + [value, onSend, disabled, isLoading], + ); + + // 处理输入变化 + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + }, + [onChange], + ); + + // 处理发送按钮点击 + const handleSendClick = useCallback(() => { + if (value.trim() && !disabled && !isLoading) { + onSend(); + } + }, [value, onSend, disabled, isLoading]); + + const canSend = value.trim() && !disabled && !isLoading; + + return ( +
+ + +
+ ); +}; + +export default ChatInput; diff --git a/src/components/screenshot-chat/ChatMessages.tsx b/src/components/screenshot-chat/ChatMessages.tsx new file mode 100644 index 000000000..4419648f5 --- /dev/null +++ b/src/components/screenshot-chat/ChatMessages.tsx @@ -0,0 +1,104 @@ +/** + * @file ChatMessages.tsx + * @description 消息列表组件,显示用户和 AI 的对话消息 + * @module components/screenshot-chat/ChatMessages + */ + +import React, { useRef, useEffect } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { ChatMessagesProps, ChatMessage } from "./types"; +import "./screenshot-chat.css"; + +/** + * 单条消息组件 + */ +const MessageItem: React.FC<{ message: ChatMessage }> = ({ message }) => { + const isUser = message.role === "user"; + + return ( +
+ {/* 用户消息显示图片 */} + {isUser && message.image && ( +
+ 截图 +
+ )} + + {/* 消息内容 */} +
+ {message.isThinking ? ( +
+ + {message.thinkingContent || "思考中..."} +
+ ) : isUser ? ( +

{message.content}

+ ) : ( +
+ + {message.content} + +
+ )} +
+ + {/* 时间戳 */} +
+ {new Date(message.timestamp).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })} +
+
+ ); +}; + +/** + * 消息列表组件 + * + * 显示用户消息和 AI 回复,支持 Markdown 渲染和自动滚动 + * + * 需求: + * - 4.5: 悬浮窗口应在可滚动区域显示 AI 回复 + * - 5.4: 当 AI 回复时,悬浮窗口应以 Markdown 格式渲染回复内容 + */ +export const ChatMessages: React.FC = ({ + messages, + className = "", +}) => { + const containerRef = useRef(null); + + // 自动滚动到底部 + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [messages]); + + if (messages.length === 0) { + return ( +
+
+ 输入问题,开始与 AI 讨论截图内容 +
+
+ ); + } + + return ( +
+ {messages.map((message) => ( + + ))} +
+ ); +}; + +export default ChatMessages; diff --git a/src/components/screenshot-chat/README.md b/src/components/screenshot-chat/README.md new file mode 100644 index 000000000..218097ee7 --- /dev/null +++ b/src/components/screenshot-chat/README.md @@ -0,0 +1,150 @@ +# 截图对话组件 (screenshot-chat) + +截图对话功能的前端组件模块,提供截图预览、对话输入、消息展示等功能。 + +## 文件索引 + +| 文件 | 描述 | +| -------------------------- | ----------------------------------- | +| `index.ts` | 模块导出入口 | +| `types.ts` | 类型定义(配置、消息、组件 Props) | +| `useScreenshotChat.ts` | 核心 Hook,管理消息、图片和 AI 通信 | +| `ScreenshotPreview.tsx` | 截图预览组件,支持缩放和拖拽查看 | +| `ChatInput.tsx` | 聊天输入框组件,支持 Enter 发送 | +| `ChatMessages.tsx` | 消息列表组件,支持 Markdown 渲染 | +| `ScreenshotChatWindow.tsx` | 悬浮窗主组件,组合所有子组件 | +| `ShortcutSettings.tsx` | 快捷键设置组件,支持录制模式 | +| `screenshot-chat.css` | 截图对话组件样式 | + +## 组件说明 + +### ScreenshotChatWindow + +悬浮窗主组件,组合截图预览、消息列表和输入框。 + +**功能特性:** + +- 组合 ScreenshotPreview, ChatInput, ChatMessages +- 支持 ESC 键关闭窗口 +- 支持窗口拖动(通过 header 区域) +- 显示错误信息和重试按钮 + +**Props:** + +- `imagePath`: 截图文件路径 +- `onClose`: 关闭窗口回调(可选) + +### ScreenshotPreview + +截图预览组件,用于在悬浮对话窗口中显示截图。 + +**功能特性:** + +- 显示截图图片 +- 支持滚轮缩放 (50% - 300%) +- 支持拖拽平移(放大后) +- 工具栏提供缩放和重置按钮 + +**Props:** + +- `src`: 图片路径或 Base64 编码 +- `alt`: 图片 alt 文本(可选) +- `className`: 自定义类名(可选) +- `maxHeight`: 最大高度,默认 300px(可选) + +### ChatInput + +聊天输入框组件。 + +**功能特性:** + +- 文本输入框 +- 发送按钮 +- Enter 键发送支持 +- 加载状态显示 + +**Props:** + +- `value`: 输入框值 +- `onChange`: 值变化回调 +- `onSend`: 发送消息回调 +- `disabled`: 是否禁用(可选) +- `isLoading`: 是否正在加载(可选) +- `placeholder`: 占位符文本(可选) + +### ChatMessages + +消息列表组件。 + +**功能特性:** + +- 显示用户消息和 AI 回复 +- Markdown 渲染支持 +- 自动滚动到最新消息 +- 显示消息时间戳 + +**Props:** + +- `messages`: 消息列表 +- `className`: 自定义类名(可选) + +### ShortcutSettings + +快捷键设置组件,用于在设置页面中配置截图快捷键。 + +**功能特性:** + +- 显示当前快捷键(用户友好格式) +- 快捷键录制模式 +- 保存/取消按钮 +- 错误提示 + +**Props:** + +- `currentShortcut`: 当前快捷键 +- `onShortcutChange`: 快捷键变更回调 +- `onValidate`: 验证快捷键回调(可选) +- `disabled`: 是否禁用(可选) + +## Hook 说明 + +### useScreenshotChat + +核心 Hook,管理截图对话的状态和 AI 通信。 + +**返回值:** + +- `messages`: 消息列表 +- `isLoading`: 是否正在加载 +- `error`: 错误信息 +- `imagePath`: 当前截图路径 +- `imageBase64`: 当前截图的 Base64 编码 +- `sendMessage(message)`: 发送消息到 AI +- `setImagePath(path)`: 设置截图路径 +- `clearMessages()`: 清空消息历史 +- `clearError()`: 清除错误 +- `retry()`: 重试上一条消息 + +## 依赖关系 + +- 使用项目统一的 CSS 变量(terminal 主题) +- 使用 `@tauri-apps/api/core` 进行 Tauri 通信 +- 使用 `react-markdown` 和 `remark-gfm` 进行 Markdown 渲染 +- 使用 `@/lib/api/agent` 进行 AI 通信 + +## 相关需求 + +- 需求 4.1: 悬浮窗口以无边框、置顶的方式打开 +- 需求 4.2: 悬浮窗口应显示截图预览 +- 需求 4.3: 悬浮窗口应提供文本输入框 +- 需求 4.4: 支持 Enter 键发送 +- 需求 4.5: 在可滚动区域显示 AI 回复 +- 需求 4.6: 支持 ESC 关闭 +- 需求 4.7: 支持窗口拖动 +- 需求 5.1: 将图片编码为 base64 +- 需求 5.2: 使用现有的 Agent API 进行 AI 通信 +- 需求 5.3: 显示加载指示器 +- 需求 5.4: 以 Markdown 格式渲染回复内容 +- 需求 5.5: 显示错误信息并提供重试选项 +- 需求 6.3: 显示当前快捷键和修改按钮 +- 需求 6.4: 支持快捷键录制模式 diff --git a/src/components/screenshot-chat/ScreenshotChatWindow.tsx b/src/components/screenshot-chat/ScreenshotChatWindow.tsx new file mode 100644 index 000000000..ab959a3a7 --- /dev/null +++ b/src/components/screenshot-chat/ScreenshotChatWindow.tsx @@ -0,0 +1,170 @@ +/** + * @file ScreenshotChatWindow.tsx + * @description 截图对话悬浮窗主组件 + * @module components/screenshot-chat/ScreenshotChatWindow + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ScreenshotPreview } from "./ScreenshotPreview"; +import { ChatInput } from "./ChatInput"; +import { ChatMessages } from "./ChatMessages"; +import { useScreenshotChat } from "./useScreenshotChat"; +import type { ScreenshotChatWindowProps } from "./types"; +import "./screenshot-chat.css"; + +/** + * 截图对话悬浮窗主组件 + * + * 组合截图预览、消息列表和输入框,提供完整的对话界面 + * + * 需求: + * - 4.1: 当截图完成时,悬浮窗口应以无边框、置顶的方式打开 + * - 4.6: 当用户按下 ESC 或点击窗口外部时,悬浮窗口应关闭 + * - 4.7: 悬浮窗口应支持用户拖动 + */ +export const ScreenshotChatWindow: React.FC = ({ + imagePath, + onClose, +}) => { + const [inputValue, setInputValue] = useState(""); + const { + messages, + isLoading, + error, + imageBase64, + sendMessage, + setImagePath, + clearError, + retry, + } = useScreenshotChat(); + + // 加载图片 + useEffect(() => { + if (imagePath) { + setImagePath(imagePath); + } + }, [imagePath, setImagePath]); + + // 处理关闭窗口 + const handleClose = useCallback(async () => { + try { + await invoke("close_screenshot_chat_window"); + } catch (err) { + console.error("关闭窗口失败:", err); + } + onClose?.(); + }, [onClose]); + + // 处理 ESC 键关闭 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleClose]); + + // 处理发送消息 + const handleSend = useCallback(async () => { + if (!inputValue.trim()) return; + const message = inputValue; + setInputValue(""); + await sendMessage(message); + }, [inputValue, sendMessage]); + + // 构建图片 src + const imageSrc = imageBase64 + ? `data:image/png;base64,${imageBase64}` + : imagePath; + + return ( +
+ {/* 窗口头部 - 可拖动区域 */} +
+ 截图对话 + +
+ + {/* 截图预览区域 */} + {imageSrc && ( +
+ +
+ )} + + {/* 对话区域 */} +
+ {/* 错误提示 */} + {error && ( +
+
+

{error}

+ + +
+
+ )} + + {/* 消息列表 */} + + + {/* 输入区域 */} + +
+ + {/* 调试信息(开发模式) */} + {import.meta.env.DEV && ( +
+ 路径: {imagePath} | Base64: {imageBase64 ? "已加载" : "未加载"} | + 消息数: {messages.length} +
+ )} +
+ ); +}; + +export default ScreenshotChatWindow; diff --git a/src/components/screenshot-chat/ScreenshotPreview.tsx b/src/components/screenshot-chat/ScreenshotPreview.tsx new file mode 100644 index 000000000..447586586 --- /dev/null +++ b/src/components/screenshot-chat/ScreenshotPreview.tsx @@ -0,0 +1,174 @@ +/** + * @file ScreenshotPreview.tsx + * @description 截图预览组件,用于悬浮对话窗口中显示截图 + * @module components/screenshot-chat/ScreenshotPreview + */ + +import React, { useState, useCallback } from "react"; + +/** 截图预览属性 */ +export interface ScreenshotPreviewProps { + /** 图片路径或 Base64 编码 */ + src: string; + /** 图片 alt 文本 */ + alt?: string; + /** 自定义类名 */ + className?: string; + /** 最大高度 */ + maxHeight?: number; +} + +/** + * 截图预览组件 + * 支持缩放和拖拽查看截图 + */ +export const ScreenshotPreview: React.FC = ({ + src, + alt = "截图预览", + className = "", + maxHeight = 300, +}) => { + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + + // 滚轮缩放 + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setScale((prev) => Math.max(0.5, Math.min(3, prev + delta))); + }, []); + + // 拖拽开始 + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (scale > 1) { + setIsDragging(true); + setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); + } + }, + [scale, position], + ); + + // 拖拽中 + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (isDragging) { + setPosition({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y, + }); + } + }, + [isDragging, dragStart], + ); + + // 拖拽结束 + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + // 重置缩放和位置 + const handleReset = useCallback(() => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }, []); + + // 放大 + const handleZoomIn = useCallback(() => { + setScale((prev) => Math.min(3, prev + 0.25)); + }, []); + + // 缩小 + const handleZoomOut = useCallback(() => { + setScale((prev) => Math.max(0.5, prev - 0.25)); + }, []); + + return ( +
+ {/* 工具栏 */} +
+ + + {Math.round(scale * 100)}% + + + +
+ + {/* 图片容器 */} +
+ {alt} 1 ? (isDragging ? "grabbing" : "grab") : "default", + }} + draggable={false} + /> +
+
+ ); +}; + +export default ScreenshotPreview; diff --git a/src/components/screenshot-chat/ShortcutSettings.tsx b/src/components/screenshot-chat/ShortcutSettings.tsx new file mode 100644 index 000000000..1a45c4902 --- /dev/null +++ b/src/components/screenshot-chat/ShortcutSettings.tsx @@ -0,0 +1,291 @@ +/** + * @file ShortcutSettings.tsx + * @description 快捷键设置组件 - 显示当前快捷键、支持录制模式和保存/取消 + * @module components/screenshot-chat/ShortcutSettings + * + * 需求: 6.3, 6.4 - 显示当前快捷键和修改按钮,支持快捷键录制模式 + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Keyboard, Check, X, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ============================================================ +// 类型定义 +// ============================================================ + +export interface ShortcutSettingsProps { + /** 当前快捷键 */ + currentShortcut: string; + /** 快捷键变更回调 */ + onShortcutChange: (shortcut: string) => Promise; + /** 验证快捷键回调 */ + onValidate?: (shortcut: string) => Promise; + /** 是否禁用 */ + disabled?: boolean; +} + +// ============================================================ +// 辅助函数 +// ============================================================ + +/** + * 将 KeyboardEvent 转换为 Tauri 快捷键格式 + */ +function keyEventToShortcut(e: KeyboardEvent): string | null { + const modifiers: string[] = []; + + // 收集修饰键 + if (e.metaKey || e.ctrlKey) { + modifiers.push("CommandOrControl"); + } + if (e.altKey) { + modifiers.push("Alt"); + } + if (e.shiftKey) { + modifiers.push("Shift"); + } + + // 获取主键 + let key = e.key; + + // 忽略单独的修饰键 + if (["Control", "Meta", "Alt", "Shift"].includes(key)) { + return null; + } + + // 转换特殊键名 + const keyMap: Record = { + " ": "Space", + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + Escape: "Escape", + Enter: "Enter", + Backspace: "Backspace", + Delete: "Delete", + Tab: "Tab", + }; + + if (keyMap[key]) { + key = keyMap[key]; + } else if (key.length === 1) { + // 单字符键转大写 + key = key.toUpperCase(); + } else if (key.startsWith("F") && /^F\d+$/.test(key)) { + // 功能键保持原样 + } else { + // 其他键首字母大写 + key = key.charAt(0).toUpperCase() + key.slice(1); + } + + // 必须有至少一个修饰键 + if (modifiers.length === 0) { + return null; + } + + return [...modifiers, key].join("+"); +} + +/** + * 格式化快捷键显示(将 Tauri 格式转换为用户友好格式) + */ +function formatShortcutDisplay(shortcut: string): string { + return shortcut + .replace( + "CommandOrControl", + navigator.platform.includes("Mac") ? "⌘" : "Ctrl", + ) + .replace("Shift", navigator.platform.includes("Mac") ? "⇧" : "Shift") + .replace("Alt", navigator.platform.includes("Mac") ? "⌥" : "Alt") + .replace(/\+/g, " + "); +} + +// ============================================================ +// 组件 +// ============================================================ + +export function ShortcutSettings({ + currentShortcut, + onShortcutChange, + onValidate, + disabled = false, +}: ShortcutSettingsProps) { + // 状态 + const [isRecording, setIsRecording] = useState(false); + const [recordedShortcut, setRecordedShortcut] = useState(null); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // Refs + const inputRef = useRef(null); + + // 开始录制 + const startRecording = useCallback(() => { + if (disabled) return; + setIsRecording(true); + setRecordedShortcut(null); + setError(null); + }, [disabled]); + + // 取消录制 + const cancelRecording = useCallback(() => { + setIsRecording(false); + setRecordedShortcut(null); + setError(null); + }, []); + + // 保存快捷键 + const saveShortcut = useCallback(async () => { + if (!recordedShortcut) return; + + setIsSaving(true); + setError(null); + + try { + // 验证快捷键 + if (onValidate) { + const isValid = await onValidate(recordedShortcut); + if (!isValid) { + setError("快捷键格式无效"); + setIsSaving(false); + return; + } + } + + // 保存快捷键 + await onShortcutChange(recordedShortcut); + setIsRecording(false); + setRecordedShortcut(null); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setIsSaving(false); + } + }, [recordedShortcut, onShortcutChange, onValidate]); + + // 键盘事件处理 + useEffect(() => { + if (!isRecording) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // ESC 取消录制 + if (e.key === "Escape") { + cancelRecording(); + return; + } + + const shortcut = keyEventToShortcut(e); + if (shortcut) { + setRecordedShortcut(shortcut); + setError(null); + } + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [isRecording, cancelRecording]); + + // 自动聚焦录制区域 + useEffect(() => { + if (isRecording && inputRef.current) { + inputRef.current.focus(); + } + }, [isRecording]); + + // 显示的快捷键 + const displayShortcut = recordedShortcut || currentShortcut; + + return ( +
+
+ + 快捷键 +
+ +
+ {/* 快捷键显示/录制区域 */} +
+ {isRecording ? ( + + {recordedShortcut + ? formatShortcutDisplay(recordedShortcut) + : "按下快捷键组合..."} + + ) : ( + {formatShortcutDisplay(displayShortcut)} + )} +
+ + {/* 操作按钮 */} + {isRecording ? ( + <> + + + + ) : ( + + )} +
+ + {/* 错误提示 */} + {error && ( +
+ + {error} +
+ )} + + {/* 录制提示 */} + {isRecording && !error && ( +

+ 按下想要设置的快捷键组合,按 ESC 取消 +

+ )} +
+ ); +} + +export default ShortcutSettings; diff --git a/src/components/screenshot-chat/index.ts b/src/components/screenshot-chat/index.ts new file mode 100644 index 000000000..b0981a415 --- /dev/null +++ b/src/components/screenshot-chat/index.ts @@ -0,0 +1,33 @@ +/** + * @file index.ts + * @description 截图对话模块导出入口 + * @module components/screenshot-chat + */ + +// 类型导出 +export type { + ScreenshotChatConfig, + MessageImage, + ChatMessage, + ScreenshotChatState, + UseScreenshotChatReturn, + ScreenshotPreviewProps, + ChatInputProps, + ChatMessagesProps, + ScreenshotChatWindowProps, +} from "./types"; + +export type { ShortcutSettingsProps } from "./ShortcutSettings"; + +// 组件导出 +export { ScreenshotPreview } from "./ScreenshotPreview"; +export { ChatInput } from "./ChatInput"; +export { ChatMessages } from "./ChatMessages"; +export { ScreenshotChatWindow } from "./ScreenshotChatWindow"; +export { ShortcutSettings } from "./ShortcutSettings"; + +// Hook 导出 +export { useScreenshotChat, readImageAsBase64 } from "./useScreenshotChat"; + +// 默认导出主组件 +export { ScreenshotChatWindow as default } from "./ScreenshotChatWindow"; diff --git a/src/components/screenshot-chat/screenshot-chat.css b/src/components/screenshot-chat/screenshot-chat.css new file mode 100644 index 000000000..752092503 --- /dev/null +++ b/src/components/screenshot-chat/screenshot-chat.css @@ -0,0 +1,643 @@ +/** + * @file screenshot-chat.css + * @description 截图对话组件样式 - 参考 Claude 的简洁设计 + * @module components/screenshot-chat + */ + +/* ============================================================================ + * 悬浮输入框容器 + * ============================================================================ */ + +.screenshot-floating-container { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding-bottom: 80px; + background: transparent; +} + +/* 背景遮罩 - 半透明模糊效果 */ +.screenshot-floating-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +/* ============================================================================ + * 悬浮输入框 + * ============================================================================ */ + +.screenshot-floating-input-wrapper { + position: relative; + z-index: 10; + display: flex; + align-items: center; + gap: 8px; + width: 600px; + max-width: calc(100vw - 40px); + padding: 8px 12px; + background: white; + border-radius: 24px; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(0, 0, 0, 0.05); +} + +/* 图片标签按钮 */ +.screenshot-image-tag { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: #f3f4f6; + border: none; + border-radius: 16px; + color: #6b7280; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.screenshot-image-tag:hover { + background: #e5e7eb; + color: #374151; +} + +.screenshot-image-tag svg { + color: #9ca3af; +} + +/* 输入框 */ +.screenshot-floating-input { + flex: 1; + padding: 8px 4px; + border: none; + background: transparent; + font-size: 15px; + color: #1f2937; + outline: none; +} + +.screenshot-floating-input::placeholder { + color: #9ca3af; +} + +.screenshot-floating-input:disabled { + opacity: 0.6; +} + +/* 右侧按钮组 */ +.screenshot-floating-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* 下拉按钮 */ +.screenshot-action-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: transparent; + border: none; + border-radius: 8px; + color: #6b7280; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.screenshot-action-btn:hover { + background: #f3f4f6; + color: #374151; +} + +/* 发送按钮 */ +.screenshot-send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #d1d5db; + border: none; + border-radius: 50%; + color: white; + cursor: not-allowed; + transition: all 0.15s ease; +} + +.screenshot-send-btn.active { + background: #f97316; + cursor: pointer; +} + +.screenshot-send-btn.active:hover { + background: #ea580c; +} + +/* ============================================================================ + * 图片预览弹窗 + * ============================================================================ */ + +.screenshot-preview-modal { + position: fixed; + z-index: 100; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 90vw; + max-height: 80vh; + background: white; + border-radius: 12px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +.screenshot-preview-close { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: rgba(0, 0, 0, 0.5); + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-preview-close:hover { + background: rgba(0, 0, 0, 0.7); +} + +.screenshot-preview-image { + display: block; + max-width: 90vw; + max-height: 80vh; + object-fit: contain; +} + +/* ============================================================================ + * 深色模式支持 + * ============================================================================ */ + +@media (prefers-color-scheme: dark) { + .screenshot-floating-input-wrapper { + background: #1f2937; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.1); + } + + .screenshot-image-tag { + background: #374151; + color: #d1d5db; + } + + .screenshot-image-tag:hover { + background: #4b5563; + color: #f3f4f6; + } + + .screenshot-image-tag svg { + color: #9ca3af; + } + + .screenshot-floating-input { + color: #f3f4f6; + } + + .screenshot-floating-input::placeholder { + color: #6b7280; + } + + .screenshot-action-btn { + color: #9ca3af; + } + + .screenshot-action-btn:hover { + background: #374151; + color: #f3f4f6; + } + + .screenshot-send-btn { + background: #4b5563; + } + + .screenshot-preview-modal { + background: #1f2937; + } +} + +/* ============================================================================ + * 旧版样式保留(兼容) + * ============================================================================ */ + +.screenshot-preview { + display: flex; + flex-direction: column; + border-radius: 8px; + overflow: hidden; + background-color: var(--terminal-bg, #1a1b26); + border: 1px solid var(--terminal-border, #3b4261); +} + +.screenshot-preview-toolbar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 8px; + background-color: var(--terminal-tab-bg, #24283b); + border-bottom: 1px solid var(--terminal-border, #3b4261); +} + +.screenshot-preview-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + color: var(--terminal-muted, #565f89); + background: transparent; + border: none; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-preview-btn:hover { + background-color: var(--terminal-tab-hover-bg, #414868); + color: var(--terminal-fg, #c0caf5); +} + +.screenshot-preview-scale { + min-width: 50px; + text-align: center; + font-size: 12px; + color: var(--terminal-muted, #565f89); + font-variant-numeric: tabular-nums; +} + +.screenshot-preview-container { + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background-color: var(--terminal-bg, #1a1b26); +} + +/* ============================================================================ + * 截图对话页面 + * ============================================================================ */ + +.screenshot-chat-page { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + background-color: var(--terminal-bg, #1a1b26); + color: var(--terminal-fg, #c0caf5); + overflow: hidden; +} + +/* 窗口头部 - 拖动区域 */ +.screenshot-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--terminal-tab-bg, #24283b); + border-bottom: 1px solid var(--terminal-border, #3b4261); + -webkit-app-region: drag; + user-select: none; +} + +.screenshot-chat-title { + font-size: 13px; + font-weight: 500; + color: var(--terminal-fg, #c0caf5); +} + +.screenshot-chat-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + color: var(--terminal-muted, #565f89); + background: transparent; + border: none; + cursor: pointer; + transition: all 0.15s ease; + -webkit-app-region: no-drag; +} + +.screenshot-chat-close-btn:hover { + background-color: #f43f5e; + color: white; +} + +/* 截图预览区域 */ +.screenshot-chat-preview { + padding: 12px; + border-bottom: 1px solid var(--terminal-border, #3b4261); +} + +/* 对话区域 */ +.screenshot-chat-conversation { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.screenshot-chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.screenshot-chat-placeholder { + text-align: center; + color: var(--terminal-muted, #565f89); + font-size: 13px; + padding: 24px; +} + +/* 输入区域 */ +.screenshot-chat-input-area { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--terminal-border, #3b4261); + background-color: var(--terminal-tab-bg, #24283b); +} + +.screenshot-chat-input { + flex: 1; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--terminal-border, #3b4261); + background-color: var(--terminal-bg, #1a1b26); + color: var(--terminal-fg, #c0caf5); + font-size: 13px; + outline: none; + transition: border-color 0.15s ease; +} + +.screenshot-chat-input:focus { + border-color: var(--terminal-accent, #7aa2f7); +} + +.screenshot-chat-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.screenshot-chat-send-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + background-color: var(--terminal-accent, #7aa2f7); + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-chat-send-btn:hover:not(:disabled) { + background-color: var(--terminal-accent-hover, #5d8bea); +} + +.screenshot-chat-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 错误状态 */ +.screenshot-chat-error { + display: flex; + align-items: center; + justify-content: center; +} + +.screenshot-chat-error-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 24px; +} + +/* 加载状态 */ +.screenshot-chat-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--terminal-muted, #565f89); +} + +.screenshot-chat-loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--terminal-border, #3b4261); + border-top-color: var(--terminal-accent, #7aa2f7); + border-radius: 50%; + animation: screenshot-chat-spin 0.8s linear infinite; +} + +@keyframes screenshot-chat-spin { + to { + transform: rotate(360deg); + } +} + +/* 调试信息 */ +.screenshot-chat-debug { + padding: 4px 12px; + background-color: var(--terminal-tab-bg, #24283b); + border-top: 1px solid var(--terminal-border, #3b4261); + font-size: 10px; + color: var(--terminal-muted, #565f89); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ============================================================================ + * 消息列表组件 + * ============================================================================ */ + +.screenshot-chat-message { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; + padding: 8px 12px; + border-radius: 8px; + max-width: 85%; +} + +.screenshot-chat-message-user { + align-self: flex-end; + background-color: var(--terminal-accent, #7aa2f7); + color: white; +} + +.screenshot-chat-message-assistant { + align-self: flex-start; + background-color: var(--terminal-tab-bg, #24283b); + border: 1px solid var(--terminal-border, #3b4261); +} + +.screenshot-chat-message-image { + margin-bottom: 8px; +} + +.screenshot-chat-message-thumbnail { + max-width: 120px; + max-height: 80px; + border-radius: 4px; + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.screenshot-chat-message-content { + font-size: 13px; + line-height: 1.5; +} + +.screenshot-chat-message-content p { + margin: 0; +} + +.screenshot-chat-message-time { + font-size: 10px; + opacity: 0.6; + align-self: flex-end; +} + +/* 思考中状态 */ +.screenshot-chat-thinking { + display: flex; + align-items: center; + gap: 8px; + color: var(--terminal-muted, #565f89); + font-style: italic; +} + +.screenshot-chat-thinking .screenshot-chat-loading-spinner { + width: 14px; + height: 14px; + border-width: 2px; +} + +/* Markdown 渲染样式 */ +.screenshot-chat-markdown { + font-size: 13px; + line-height: 1.6; +} + +.screenshot-chat-markdown p { + margin: 0 0 8px 0; +} + +.screenshot-chat-markdown p:last-child { + margin-bottom: 0; +} + +.screenshot-chat-markdown code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + padding: 2px 4px; + border-radius: 3px; + background-color: rgba(0, 0, 0, 0.2); +} + +.screenshot-chat-markdown pre { + margin: 8px 0; + padding: 8px; + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.3); + overflow-x: auto; +} + +.screenshot-chat-markdown pre code { + padding: 0; + background: transparent; +} + +.screenshot-chat-markdown ul, +.screenshot-chat-markdown ol { + margin: 8px 0; + padding-left: 20px; +} + +.screenshot-chat-markdown li { + margin-bottom: 4px; +} + +.screenshot-chat-markdown strong { + font-weight: 600; +} + +.screenshot-chat-markdown em { + font-style: italic; +} + +.screenshot-chat-markdown a { + color: var(--terminal-accent, #7aa2f7); + text-decoration: none; +} + +.screenshot-chat-markdown a:hover { + text-decoration: underline; +} + +.screenshot-chat-markdown blockquote { + margin: 8px 0; + padding-left: 12px; + border-left: 3px solid var(--terminal-accent, #7aa2f7); + color: var(--terminal-muted, #565f89); +} + +/* 重试按钮 */ +.screenshot-chat-retry-btn { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 8px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--terminal-border, #3b4261); + background: transparent; + color: var(--terminal-muted, #565f89); + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-chat-retry-btn:hover { + background-color: var(--terminal-tab-hover-bg, #414868); + color: var(--terminal-fg, #c0caf5); +} diff --git a/src/components/screenshot-chat/types.ts b/src/components/screenshot-chat/types.ts new file mode 100644 index 000000000..3738461fb --- /dev/null +++ b/src/components/screenshot-chat/types.ts @@ -0,0 +1,152 @@ +/** + * @file types.ts + * @description 截图对话模块类型定义 + * @module components/screenshot-chat/types + */ + +// ============================================================ +// 配置类型 +// ============================================================ + +/** + * 截图对话功能配置 + * 需求: 1.1 - 实验室功能应提供 screenshot_chat.enabled 布尔开关 + */ +export interface ScreenshotChatConfig { + /** 是否启用截图对话功能 */ + enabled: boolean; + /** 触发截图的全局快捷键 */ + shortcut: string; +} + +// ============================================================ +// 消息类型 +// ============================================================ + +/** + * 消息图片 + * 需求: 5.1 - 截图对话模块应将图片编码为 base64 + */ +export interface MessageImage { + /** Base64 编码的图片数据 */ + data: string; + /** 媒体类型,如 "image/png" */ + mediaType: string; +} + +/** + * 聊天消息 + * 需求: 4.5 - 悬浮窗口应在可滚动区域显示 AI 回复 + */ +export interface ChatMessage { + /** 消息唯一标识 */ + id: string; + /** 消息角色:用户或助手 */ + role: "user" | "assistant"; + /** 消息文本内容 */ + content: string; + /** 附带的图片(用户消息可能包含截图) */ + image?: MessageImage; + /** 消息时间戳 */ + timestamp: number; + /** 是否正在思考中(助手消息) */ + isThinking?: boolean; + /** 思考中的提示文本 */ + thinkingContent?: string; +} + +// ============================================================ +// Hook 状态类型 +// ============================================================ + +/** + * 截图对话 Hook 状态 + */ +export interface ScreenshotChatState { + /** 消息列表 */ + messages: ChatMessage[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 错误信息 */ + error: string | null; + /** 当前截图路径 */ + imagePath: string | null; + /** 当前截图的 Base64 编码 */ + imageBase64: string | null; +} + +/** + * 截图对话 Hook 返回值 + */ +export interface UseScreenshotChatReturn extends ScreenshotChatState { + /** 发送消息到 AI */ + sendMessage: (message: string) => Promise; + /** 设置截图路径 */ + setImagePath: (path: string) => void; + /** 清空消息历史 */ + clearMessages: () => void; + /** 清除错误 */ + clearError: () => void; + /** 重试上一条消息 */ + retry: () => Promise; +} + +// ============================================================ +// 组件 Props 类型 +// ============================================================ + +/** + * 截图预览组件属性 + * 需求: 4.2 - 悬浮窗口应显示截图预览 + */ +export interface ScreenshotPreviewProps { + /** 图片路径或 Base64 编码 */ + src: string; + /** 图片 alt 文本 */ + alt?: string; + /** 自定义类名 */ + className?: string; + /** 最大高度 */ + maxHeight?: number; +} + +/** + * 聊天输入框组件属性 + * 需求: 4.3, 4.4 - 悬浮窗口应提供文本输入框,支持 Enter 发送 + */ +export interface ChatInputProps { + /** 输入框值 */ + value: string; + /** 值变化回调 */ + onChange: (value: string) => void; + /** 发送消息回调 */ + onSend: () => void; + /** 是否禁用 */ + disabled?: boolean; + /** 是否正在加载 */ + isLoading?: boolean; + /** 占位符文本 */ + placeholder?: string; +} + +/** + * 消息列表组件属性 + * 需求: 4.5, 5.4 - 显示 AI 回复,支持 Markdown 渲染 + */ +export interface ChatMessagesProps { + /** 消息列表 */ + messages: ChatMessage[]; + /** 自定义类名 */ + className?: string; +} + +/** + * 悬浮窗主组件属性 + * 需求: 4.1, 4.6, 4.7 - 无边框置顶窗口,支持 ESC 关闭和拖动 + */ +export interface ScreenshotChatWindowProps { + /** 截图路径 */ + imagePath: string; + /** 关闭窗口回调 */ + onClose?: () => void; +} diff --git a/src/components/screenshot-chat/useScreenshotChat.ts b/src/components/screenshot-chat/useScreenshotChat.ts new file mode 100644 index 000000000..ab6c67417 --- /dev/null +++ b/src/components/screenshot-chat/useScreenshotChat.ts @@ -0,0 +1,313 @@ +/** + * @file useScreenshotChat.ts + * @description 截图对话核心 Hook,管理消息、图片和 AI 通信 + * @module components/screenshot-chat/useScreenshotChat + */ + +import { useState, useCallback, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { toast } from "sonner"; +import type { + ChatMessage, + MessageImage, + UseScreenshotChatReturn, +} from "./types"; +import { parseStreamEvent, type StreamEvent } from "@/lib/api/agent"; + +/** + * 读取图片文件并转换为 Base64 + * 需求: 5.1 - 截图对话模块应将图片编码为 base64 + * + * @param imagePath - 图片文件路径 + * @returns Base64 编码的图片数据 + */ +export async function readImageAsBase64(imagePath: string): Promise { + try { + const base64 = await invoke("read_image_as_base64", { + path: imagePath, + }); + return base64; + } catch (error) { + console.error("读取图片失败:", error); + throw new Error(`读取图片失败: ${error}`); + } +} + +/** + * 截图对话 Hook + * + * 提供截图对话功能的核心状态管理和 AI 通信能力 + * + * 需求: + * - 5.1: 将图片编码为 base64 + * - 5.2: 使用现有的 Agent API 进行 AI 通信 + * - 5.3: 显示加载指示器 + * - 5.5: 显示错误信息并提供重试选项 + */ +export function useScreenshotChat(): UseScreenshotChatReturn { + // 状态 + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [imagePath, setImagePathState] = useState(null); + const [imageBase64, setImageBase64] = useState(null); + + // 用于重试的最后一条消息 + const lastMessageRef = useRef(null); + + // 会话 ID + const sessionIdRef = useRef(null); + + /** + * 设置截图路径并加载图片 + */ + const setImagePath = useCallback(async (path: string) => { + setImagePathState(path); + setError(null); + + try { + const base64 = await readImageAsBase64(path); + setImageBase64(base64); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : "加载图片失败"; + setError(errorMsg); + toast.error(errorMsg); + } + }, []); + + /** + * 创建或获取会话 + */ + const ensureSession = useCallback(async (): Promise => { + if (sessionIdRef.current) { + return sessionIdRef.current; + } + + try { + const response = await invoke<{ session_id: string }>( + "agent_create_session", + { + providerType: "claude", + model: "claude-sonnet-4-5", + }, + ); + sessionIdRef.current = response.session_id; + return response.session_id; + } catch (err) { + console.error("创建会话失败:", err); + return null; + } + }, []); + + /** + * 发送消息到 AI + * 需求: 5.2 - 使用现有的 Agent API 进行 AI 通信 + */ + const sendMessage = useCallback( + async (message: string) => { + if (!message.trim()) return; + if (!imageBase64) { + setError("请先加载截图"); + return; + } + + // 保存消息用于重试 + lastMessageRef.current = message; + setError(null); + setIsLoading(true); + + // 创建用户消息 + const userMsg: ChatMessage = { + id: crypto.randomUUID(), + role: "user", + content: message, + image: + messages.length === 0 + ? { data: imageBase64, mediaType: "image/png" } + : undefined, + timestamp: Date.now(), + }; + + // 创建助手消息占位符 + const assistantMsgId = crypto.randomUUID(); + const assistantMsg: ChatMessage = { + id: assistantMsgId, + role: "assistant", + content: "", + timestamp: Date.now(), + isThinking: true, + thinkingContent: "思考中...", + }; + + setMessages((prev) => [...prev, userMsg, assistantMsg]); + + let accumulatedContent = ""; + let unlisten: UnlistenFn | null = null; + + try { + // 确保有会话 + const sessionId = await ensureSession(); + if (!sessionId) { + throw new Error("无法创建会话"); + } + + // 创建唯一事件名称 + const eventName = `screenshot_chat_stream_${assistantMsgId}`; + + // 设置事件监听器 + unlisten = await listen(eventName, (event) => { + const data = parseStreamEvent(event.payload); + if (!data) return; + + switch (data.type) { + case "text_delta": + accumulatedContent += data.text; + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMsgId + ? { + ...msg, + content: accumulatedContent, + isThinking: false, + thinkingContent: undefined, + } + : msg, + ), + ); + break; + + case "done": + case "final_done": + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMsgId + ? { + ...msg, + isThinking: false, + content: accumulatedContent || "(无响应)", + } + : msg, + ), + ); + setIsLoading(false); + if (unlisten) { + unlisten(); + unlisten = null; + } + break; + + case "error": + setError(data.message); + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMsgId + ? { + ...msg, + isThinking: false, + content: `错误: ${data.message}`, + } + : msg, + ), + ); + setIsLoading(false); + if (unlisten) { + unlisten(); + unlisten = null; + } + break; + } + }); + + // 准备图片数据(只在第一条消息时发送图片) + const images: MessageImage[] = + messages.length === 0 + ? [{ data: imageBase64, mediaType: "image/png" }] + : []; + + // 发送流式请求 + await invoke("native_agent_chat_stream", { + message, + eventName, + sessionId, + model: "claude-sonnet-4-5", + images: + images.length > 0 + ? images.map((img) => ({ + data: img.data, + media_type: img.mediaType, + })) + : undefined, + provider: "claude", + }); + } catch (err) { + console.error("发送消息失败:", err); + const errorMsg = err instanceof Error ? err.message : "发送失败"; + setError(errorMsg); + toast.error(errorMsg); + + // 移除失败的助手消息 + setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId)); + setIsLoading(false); + + if (unlisten) { + unlisten(); + } + } + }, + [imageBase64, messages.length, ensureSession], + ); + + /** + * 清空消息历史 + */ + const clearMessages = useCallback(() => { + setMessages([]); + sessionIdRef.current = null; + lastMessageRef.current = null; + }, []); + + /** + * 清除错误 + */ + const clearError = useCallback(() => { + setError(null); + }, []); + + /** + * 重试上一条消息 + * 需求: 5.5 - 显示错误信息并提供重试选项 + */ + const retry = useCallback(async () => { + if (lastMessageRef.current) { + // 移除最后一条失败的助手消息 + setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if ( + lastMsg?.role === "assistant" && + lastMsg.content.startsWith("错误:") + ) { + return prev.slice(0, -1); + } + return prev; + }); + + await sendMessage(lastMessageRef.current); + } + }, [sendMessage]); + + return { + messages, + isLoading, + error, + imagePath, + imageBase64, + sendMessage, + setImagePath, + clearMessages, + clearError, + retry, + }; +} + +export default useScreenshotChat; diff --git a/src/components/settings/ExperimentalSettings.tsx b/src/components/settings/ExperimentalSettings.tsx new file mode 100644 index 000000000..91c72d1f4 --- /dev/null +++ b/src/components/settings/ExperimentalSettings.tsx @@ -0,0 +1,268 @@ +/** + * @file ExperimentalSettings.tsx + * @description 实验室设置页面 - 管理实验性功能的开关和配置 + * @module components/settings/ExperimentalSettings + * + * 需求: 6.1, 6.2, 6.3, 6.5 - 实验室标签页,截图对话功能开关,快捷键设置,权限警告 + */ + +import { useState, useEffect, useCallback } from "react"; +import { FlaskConical, Camera, AlertTriangle, RefreshCw } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + getExperimentalConfig, + saveExperimentalConfig, + validateShortcut, + updateScreenshotShortcut, + ExperimentalFeatures, +} from "@/hooks/useTauri"; +import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings"; + +// ============================================================ +// 组件 +// ============================================================ + +export function ExperimentalSettings() { + // 状态 + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + // 检测是否为 macOS(使用 userAgentData 或 userAgent 替代已弃用的 platform) + const isMacOS = navigator.userAgent.includes("Mac"); + + // 加载配置 + const loadConfig = useCallback(async () => { + setLoading(true); + setError(null); + try { + const experimentalConfig = await getExperimentalConfig(); + setConfig(experimentalConfig); + } catch (err) { + console.error("加载实验室配置失败:", err); + setError(err instanceof Error ? err.message : "加载配置失败"); + // 设置默认配置 + setConfig({ + screenshot_chat: { + enabled: false, + shortcut: "CommandOrControl+Alt+Q", + }, + }); + } finally { + setLoading(false); + } + }, []); + + // 初始加载 + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + // 切换截图对话功能开关 + const handleToggleScreenshotChat = useCallback(async () => { + if (!config) return; + + const newEnabled = !config.screenshot_chat.enabled; + const newConfig: ExperimentalFeatures = { + ...config, + screenshot_chat: { + ...config.screenshot_chat, + enabled: newEnabled, + }, + }; + + setSaving(true); + setMessage(null); + + try { + await saveExperimentalConfig(newConfig); + setConfig(newConfig); + setMessage({ + type: "success", + text: newEnabled ? "截图对话功能已启用" : "截图对话功能已禁用", + }); + setTimeout(() => setMessage(null), 2000); + } catch (err) { + console.error("保存配置失败:", err); + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "保存失败", + }); + } finally { + setSaving(false); + } + }, [config]); + + // 更新快捷键 + const handleShortcutChange = useCallback( + async (newShortcut: string) => { + if (!config) return; + + await updateScreenshotShortcut(newShortcut); + setConfig({ + ...config, + screenshot_chat: { + ...config.screenshot_chat, + shortcut: newShortcut, + }, + }); + setMessage({ type: "success", text: "快捷键已更新" }); + setTimeout(() => setMessage(null), 2000); + }, + [config], + ); + + // 验证快捷键 + const handleValidateShortcut = useCallback(async (shortcut: string) => { + try { + return await validateShortcut(shortcut); + } catch { + return false; + } + }, []); + + // 加载中状态 + if (loading) { + return ( +
+ +
+ ); + } + + // 错误状态 + if (error && !config) { + return ( +
+
+ + 加载配置失败 +
+

{error}

+ +
+ ); + } + + return ( +
+ {/* 页面标题 */} +
+ +
+

实验室功能

+

+ 这些功能仍在开发中,可能不稳定 +

+
+
+ + {/* 消息提示 */} + {message && ( +
+ {message.text} +
+ )} + + {/* 截图对话功能 */} +
+
+
+ +
+

截图对话

+

+ 使用全局快捷键截取屏幕区域,并与 AI 进行对话 +

+
+
+
+ + {/* 更多实验功能占位 */} +
+

更多实验功能即将推出...

+
+
+ ); +} + +export default ExperimentalSettings; diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 186a6299a..8bcf05d46 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { RemoteManagementSettings } from "./RemoteManagementSettings"; import { ExtensionsSettings } from "./ExtensionsSettings"; import { DeveloperSettings } from "./DeveloperSettings"; import { ConnectionsSettings } from "./ConnectionsSettings"; +import { ExperimentalSettings } from "./ExperimentalSettings"; type SettingsTab = | "general" @@ -16,6 +17,7 @@ type SettingsTab = | "security" | "advanced" | "extensions" + | "experimental" | "developer" | "about"; @@ -25,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; experimental?: boolean }[] = [ { id: "security", label: "安全" }, { id: "advanced", label: "高级" }, { id: "extensions", label: "扩展", experimental: true }, + { id: "experimental", label: "实验室", experimental: true }, { id: "developer", label: "开发者" }, { id: "about", label: "关于" }, ]; @@ -80,6 +83,7 @@ export function SettingsPage() {
)} {activeTab === "extensions" && } + {activeTab === "experimental" && } {activeTab === "developer" && } {activeTab === "about" && } diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 78805f052..42b2afef2 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -7,3 +7,4 @@ export { TlsSettings } from "./TlsSettings"; export { QuotaSettings } from "./QuotaSettings"; export { RemoteManagementSettings } from "./RemoteManagementSettings"; export { DeveloperSettings } from "./DeveloperSettings"; +export { ExperimentalSettings } from "./ExperimentalSettings"; diff --git a/src/components/terminal/TerminalWorkspace.tsx b/src/components/terminal/TerminalWorkspace.tsx index d7581e4b8..d3d931a25 100644 --- a/src/components/terminal/TerminalWorkspace.tsx +++ b/src/components/terminal/TerminalWorkspace.tsx @@ -13,6 +13,7 @@ * - 支持 Terminal/Files/Web/Sysinfo 面板类型 * - Terminal 类型支持多实例 * - 右侧小部件栏 + * - AI 面板可控制活动终端 */ import { useState, useCallback, useRef } from "react"; @@ -50,6 +51,8 @@ export interface SidePanel { cwd?: string; /** 连接配置(仅 terminal 类型使用) */ connection?: ConnectionListEntry; + /** 终端会话 ID(仅 terminal 类型使用,由 TerminalPanel 回调设置) */ + sessionId?: string; } // ============================================================================ @@ -221,12 +224,34 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { // AI 面板状态 const [showAIPanel, setShowAIPanel] = useState(false); + // 活动终端面板 ID(用于 AI 控制) + const [activeTerminalPanelId, setActiveTerminalPanelId] = + useState("main-terminal"); + // 终端输出引用(用于 AI 上下文) const terminalOutputRef = useRef(null); // 连接编辑器模态窗口状态 const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false); + // 获取活动终端的会话 ID + const getActiveTerminalSessionId = useCallback((): string | null => { + const activePanel = panels.find( + (p) => p.id === activeTerminalPanelId && p.type === "terminal", + ); + return activePanel?.sessionId || null; + }, [panels, activeTerminalPanelId]); + + // 更新终端面板的会话 ID + const updatePanelSessionId = useCallback( + (panelId: string, sessionId: string) => { + setPanels((prev) => + prev.map((p) => (p.id === panelId ? { ...p, sessionId } : p)), + ); + }, + [], + ); + // 添加面板 - 所有类型都允许多开 const addPanel = useCallback( ( @@ -321,7 +346,15 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { const renderPanelContent = (panel: SidePanel) => { switch (panel.type) { case "terminal": - return ; + return ( + + updatePanelSessionId(panel.id, sessionId) + } + /> + ); case "files": return ; case "web": @@ -329,7 +362,12 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { case "sysinfo": return ; case "ai": - return ; + return ( + + ); default: return null; } @@ -383,14 +421,28 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
- +
)} {/* 所有面板统一渲染,都可以关闭和多开 */} {panels.map((panel) => ( - + { + // 点击终端面板时设置为活动终端 + if (panel.type === "terminal") { + setActiveTerminalPanelId(panel.id); + } + }} + > {panel.type === "terminal" ? ( ( + + + + + +); + +const CommandBox = styled.div` + background: #0f172a; + border: 1px solid #1e293b; + border-radius: 6px; + padding: 10px 12px; + font-family: "Hack", "Menlo", monospace; + font-size: 13px; + color: #e2e8f0; + margin-bottom: 12px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 8px; + justify-content: flex-end; +`; + +const Button = styled.button<{ $variant?: "approve" | "reject" }>` + padding: 6px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + border: none; + + ${({ $variant }) => + $variant === "approve" + ? ` + background: #22c55e; + color: white; + &:hover { + background: #16a34a; + } + ` + : ` + background: #334155; + color: #94a3b8; + &:hover { + background: #475569; + color: #e2e8f0; + } + `} +`; + +const HelpText = styled.p` + font-size: 12px; + color: #64748b; + margin: 0 0 12px 0; +`; + +// ============================================================================ +// 组件 +// ============================================================================ + +interface CommandApprovalProps { + /** 待审批的命令 */ + command: PendingCommand; + /** 批准回调 */ + onApprove: (commandId: string) => void; + /** 拒绝回调 */ + onReject: (commandId: string) => void; +} + +/** + * 命令审批组件 + */ +export function CommandApproval({ + command, + onApprove, + onReject, +}: CommandApprovalProps) { + return ( + + + + AI 请求执行命令 + + + {command.command} + + 此命令将在当前终端中执行。请确认是否允许。 + + + + + + + ); +} + +// ============================================================================ +// 命令列表组件 +// ============================================================================ + +interface CommandApprovalListProps { + /** 待审批的命令列表 */ + commands: PendingCommand[]; + /** 批准回调 */ + onApprove: (commandId: string) => void; + /** 拒绝回调 */ + onReject: (commandId: string) => void; +} + +/** + * 命令审批列表组件 + */ +export function CommandApprovalList({ + commands, + onApprove, + onReject, +}: CommandApprovalListProps) { + if (commands.length === 0) { + return null; + } + + return ( + <> + {commands.map((cmd) => ( + + ))} + + ); +} + +export default CommandApproval; diff --git a/src/components/terminal/ai/README.md b/src/components/terminal/ai/README.md index 649c3d61f..39e7bb954 100644 --- a/src/components/terminal/ai/README.md +++ b/src/components/terminal/ai/README.md @@ -11,6 +11,7 @@ Terminal AI 是终端内置的 AI 助手功能,参考 Waveterm 的 AI 面板 - 支持终端上下文(Widget Context) - 流式响应显示 - 工具调用支持 +- **AI 控制终端**:AI 可以向活动终端发送命令(需用户审批) ## 文件索引 @@ -18,12 +19,14 @@ Terminal AI 是终端内置的 AI 助手功能,参考 Waveterm 的 AI 面板 |------|------| | `index.ts` | 模块导出 | | `types.ts` | 类型定义 | -| `useTerminalAI.ts` | Terminal AI Hook | +| `useTerminalAI.ts` | Terminal AI Hook(含终端控制) | | `TerminalAIPanel.tsx` | AI 面板主组件 | | `TerminalAIInput.tsx` | 输入框组件 | | `TerminalAIMessages.tsx` | 消息列表组件 | | `TerminalAIModeSelector.tsx` | 模式/模型选择器 | | `TerminalAIWelcome.tsx` | 欢迎页面组件 | +| `CommandApproval.tsx` | 命令审批组件 | +| `TerminalController.ts` | 终端控制器(管理 AI 与终端通信) | ## 使用方式 @@ -36,8 +39,14 @@ function MyComponent() { return "$ ls -la\ntotal 0\n..."; }; + // 终端会话 ID(用于 AI 控制终端) + const terminalSessionId = "session-123"; + return ( - + ); } ``` @@ -57,6 +66,15 @@ function MyComponent() { - OAuth 凭证(Kiro、Gemini、Antigravity 等) - API Key 凭证(OpenAI、Claude 等) +### AI 控制终端(新功能) + +AI 可以向活动终端发送命令,流程如下: +1. AI 生成命令建议 +2. 用户在审批对话框中确认 +3. 命令发送到终端执行 + +参考 Waveterm 的 `sendDataToController()` 机制实现。 + ### 快捷操作 欢迎页面提供快捷操作按钮: @@ -68,6 +86,7 @@ function MyComponent() { ## 依赖 - `@/lib/api/agent` - Agent API +- `@/lib/terminal-api` - 终端 API(用于发送命令) - `@/hooks/useProviderPool` - Provider 凭证 - `@/hooks/useApiKeyProvider` - API Key 凭证 - `@/hooks/useModelRegistry` - 模型注册表 diff --git a/src/components/terminal/ai/TerminalAIMessages.tsx b/src/components/terminal/ai/TerminalAIMessages.tsx index a6315f902..dec7ea4d3 100644 --- a/src/components/terminal/ai/TerminalAIMessages.tsx +++ b/src/components/terminal/ai/TerminalAIMessages.tsx @@ -9,6 +9,7 @@ import React, { useRef, useEffect, useState, useCallback, memo } from "react"; import { cn } from "@/lib/utils"; import type { AIMessage } from "./types"; +import type { ToolCallState } from "@/lib/api/agent"; // ============================================================================ // 子组件 @@ -100,43 +101,82 @@ MessageContent.displayName = "MessageContent"; /** * 工具调用显示 */ -const ToolCallDisplay = memo( - ({ - toolCall, - }: { - toolCall: { - id: string; - name: string; - status: string; - result?: { success: boolean; output?: string; error?: string }; - }; - }) => { - const statusIcon = - toolCall.status === "completed" - ? "✓" - : toolCall.status === "failed" - ? "✗" - : "•"; - const statusColor = - toolCall.status === "completed" - ? "text-green-500" - : toolCall.status === "failed" - ? "text-red-500" - : "text-zinc-400"; +const ToolCallDisplay = memo(({ toolCall }: { toolCall: ToolCallState }) => { + const isCompleted = toolCall.status === "completed"; + const isFailed = toolCall.status === "failed"; + const isRunning = toolCall.status === "running"; - return ( -
-
- {statusIcon} - {toolCall.name} -
- {toolCall.result?.error && ( -
{toolCall.result.error}
+ // 状态图标 + const statusIcon = isCompleted ? "✓" : isFailed ? "✗" : "•"; + + // 背景色:成功用绿色调,失败用红色调,运行中用灰色 + const bgColor = isCompleted + ? "bg-green-900/30 border-green-700/50" + : isFailed + ? "bg-red-900/30 border-red-700/50" + : "bg-zinc-800/60 border-zinc-700"; + + // 图标颜色 + const iconColor = isCompleted + ? "text-green-400" + : isFailed + ? "text-red-400" + : "text-zinc-400"; + + // 工具名称颜色 + const nameColor = isCompleted + ? "text-green-300" + : isFailed + ? "text-red-300" + : "text-zinc-300"; + + return ( +
+ {/* 工具名称和状态 */} +
+ + {statusIcon} + + {toolCall.name} + {isRunning && ( + 执行中... )}
- ); - }, -); + + {/* 显示结果 */} + {toolCall.result && ( +
+ {/* 成功输出 */} + {toolCall.result.success && toolCall.result.output && ( +
+ {toolCall.result.output.length > 500 + ? toolCall.result.output.slice(0, 500) + "..." + : toolCall.result.output} +
+ )} + + {/* 错误信息 */} + {toolCall.result.error && ( +
+ 错误: + {toolCall.result.error} +
+ )} + + {/* 退出码(如果有) */} + {!toolCall.result.success && !toolCall.result.error && ( +
命令执行失败
+ )} +
+ )} +
+ ); +}); ToolCallDisplay.displayName = "ToolCallDisplay"; diff --git a/src/components/terminal/ai/TerminalAIModeSelector.tsx b/src/components/terminal/ai/TerminalAIModeSelector.tsx index 061ff4c29..8f9700982 100644 --- a/src/components/terminal/ai/TerminalAIModeSelector.tsx +++ b/src/components/terminal/ai/TerminalAIModeSelector.tsx @@ -145,11 +145,13 @@ export const TerminalAIModeSelector: React.FC = ({ } if (!providerMap.has(key)) { + // 使用 provider.type 映射到 registryId,而不是 provider.id + const registryId = getRegistryIdFromType(provider.type); providerMap.set(key, { key, label, - registryId: provider.id, - fallbackRegistryId: getRegistryIdFromType(provider.type), + registryId, + fallbackRegistryId: registryId, type: provider.type, }); } @@ -184,37 +186,34 @@ export const TerminalAIModeSelector: React.FC = ({ } // 从 model_registry 获取 - let models = registryModels - .filter((m) => m.provider_id === selectedProvider.registryId) - .map((m) => m.id); + let models = registryModels.filter( + (m) => m.provider_id === selectedProvider.registryId, + ); if (models.length === 0 && selectedProvider.fallbackRegistryId) { - models = registryModels - .filter((m) => m.provider_id === selectedProvider.fallbackRegistryId) - .map((m) => m.id); + models = registryModels.filter( + (m) => m.provider_id === selectedProvider.fallbackRegistryId, + ); } - // 排序 - return models.sort((a, b) => { - const aIsLatest = a.includes("-latest"); - const bIsLatest = b.includes("-latest"); + // 排序:使用 release_date 和 is_latest 字段 + const sortedModels = [...models].sort((a, b) => { + // 1. is_latest 优先 + if (a.is_latest && !b.is_latest) return -1; + if (!a.is_latest && b.is_latest) return 1; - if (aIsLatest && !bIsLatest) return -1; - if (!aIsLatest && bIsLatest) return 1; - - const dateRegex = /-(\d{8})$/; - const aMatch = a.match(dateRegex); - const bMatch = b.match(dateRegex); - - if (aMatch && bMatch) { - return bMatch[1].localeCompare(aMatch[1]); + // 2. 按 release_date 降序(最新的在前) + if (a.release_date && b.release_date) { + return b.release_date.localeCompare(a.release_date); } + if (a.release_date && !b.release_date) return -1; + if (!a.release_date && b.release_date) return 1; - if (aMatch && !bMatch) return -1; - if (!aMatch && bMatch) return 1; - - return b.localeCompare(a); + // 3. 按 display_name 字母序 + return a.display_name.localeCompare(b.display_name); }); + + return sortedModels.map((m) => m.id); }, [selectedProvider, registryModels, aliasConfig]); // 自动选择第一个模型 diff --git a/src/components/terminal/ai/TerminalAIPanel.tsx b/src/components/terminal/ai/TerminalAIPanel.tsx index b0594b25e..1fc990b2b 100644 --- a/src/components/terminal/ai/TerminalAIPanel.tsx +++ b/src/components/terminal/ai/TerminalAIPanel.tsx @@ -3,11 +3,12 @@ * @description Terminal AI 面板主组件 * @module components/terminal/ai/TerminalAIPanel * - * 参考 Waveterm 的 AIPanel 设计,实现终端 AI 助手面板 + * 参考 Waveterm 的 AIPanel 设计,实现终端 AI 助手面板。 + * 支持 AI 控制终端执行命令(需用户审批)。 */ -import React, { useState, useCallback } from "react"; -import { Sparkles, MoreVertical, Trash2 } from "lucide-react"; +import React, { useState, useCallback, useEffect } from "react"; +import { Sparkles, MoreVertical, Trash2, Terminal, Unplug } from "lucide-react"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { @@ -20,6 +21,7 @@ import { TerminalAIModeSelector } from "./TerminalAIModeSelector"; import { TerminalAIMessages } from "./TerminalAIMessages"; import { TerminalAIInput } from "./TerminalAIInput"; import { TerminalAIWelcome } from "./TerminalAIWelcome"; +import { CommandApprovalList } from "./CommandApproval"; import { useTerminalAI } from "./useTerminalAI"; // ============================================================================ @@ -29,6 +31,8 @@ import { useTerminalAI } from "./useTerminalAI"; interface TerminalAIPanelProps { /** 获取终端输出的回调 */ getTerminalOutput?: () => string | null; + /** 终端会话 ID(用于 AI 控制终端) */ + terminalSessionId?: string | null; /** 自定义类名 */ className?: string; } @@ -39,6 +43,7 @@ interface TerminalAIPanelProps { export const TerminalAIPanel: React.FC = ({ getTerminalOutput, + terminalSessionId, className, }) => { const [input, setInput] = useState(""); @@ -54,8 +59,25 @@ export const TerminalAIPanel: React.FC = ({ sendMessage, clearMessages, toggleWidgetContext, + toggleAutoExecute, + // 终端控制 + isTerminalConnected, + pendingCommands, + connectTerminal, + disconnectTerminal, + approveCommand, + rejectCommand, } = useTerminalAI(getTerminalOutput); + // 当终端会话 ID 变化时,自动连接/断开 + useEffect(() => { + if (terminalSessionId) { + connectTerminal(terminalSessionId); + } else { + disconnectTerminal(); + } + }, [terminalSessionId, connectTerminal, disconnectTerminal]); + /** * 处理发送 */ @@ -74,6 +96,8 @@ export const TerminalAIPanel: React.FC = ({ }, []); const hasMessages = messages.length > 0; + const hasPendingCommands = + pendingCommands.filter((c) => c.status === "pending").length > 0; return (
= ({
Terminal AI + {/* 终端连接状态指示器 */} + {isTerminalConnected ? ( + + + 已连接 + + ) : ( + + + 未连接 + + )}
{/* 更多菜单 */} @@ -121,6 +157,19 @@ export const TerminalAIPanel: React.FC = ({ />
+ {/* 自动执行开关 */} +
+
+ 自动执行命令 + AI 命令无需手动批准 +
+ +
+ {/* 模式选择器 */}
= ({ />
+ {/* 待审批命令 */} + {hasPendingCommands && ( +
+ c.status === "pending")} + onApprove={approveCommand} + onReject={rejectCommand} + /> +
+ )} + {/* 消息区域 */}
{hasMessages ? ( @@ -146,9 +206,7 @@ export const TerminalAIPanel: React.FC = ({ onChange={setInput} onSubmit={handleSend} isSending={isSending} - placeholder={ - hasMessages ? "Continue..." : "Ask Terminal AI anything..." - } + placeholder={hasMessages ? "继续对话..." : "向 Terminal AI 提问..."} />
); diff --git a/src/components/terminal/ai/TerminalController.ts b/src/components/terminal/ai/TerminalController.ts new file mode 100644 index 000000000..3c9f25075 --- /dev/null +++ b/src/components/terminal/ai/TerminalController.ts @@ -0,0 +1,315 @@ +/** + * @file TerminalController.ts + * @description 终端控制器 - 管理 AI 与终端的通信 + * @module components/terminal/ai/TerminalController + * + * 提供 AI 控制终端的核心能力: + * - 发送命令到活动终端 + * - 获取终端输出上下文 + * - 跟踪终端状态(Shell Integration) + * + * ## 架构说明(参考 Waveterm) + * Waveterm 使用 `sendDataToController()` 方法将命令发送到终端。 + * 我们实现类似的机制,通过 `writeToTerminal` API 发送数据。 + */ + +import { writeToTerminal } from "@/lib/terminal-api"; +import type { TermWrap } from "../termwrap"; + +/** 终端控制器配置 */ +export interface TerminalControllerConfig { + /** 是否需要用户确认执行命令 */ + requireApproval?: boolean; + /** 命令执行超时(毫秒) */ + timeout?: number; +} + +/** 命令执行状态 */ +export type CommandExecutionStatus = + | "pending" + | "approved" + | "rejected" + | "executing" + | "completed" + | "failed" + | "timeout"; + +/** 待执行命令 */ +export interface PendingCommand { + id: string; + command: string; + status: CommandExecutionStatus; + createdAt: Date; + executedAt?: Date; + completedAt?: Date; + error?: string; +} + +/** 终端控制器回调 */ +export interface TerminalControllerCallbacks { + /** 命令需要审批时调用 */ + onCommandPending?: (command: PendingCommand) => void; + /** 命令状态变化时调用 */ + onCommandStatusChange?: (command: PendingCommand) => void; + /** 命令执行完成时调用 */ + onCommandComplete?: (command: PendingCommand) => void; +} + +/** + * 终端控制器 + * + * 管理 AI 与终端的通信,支持命令审批流程。 + */ +export class TerminalController { + private sessionId: string | null = null; + private termWrap: TermWrap | null = null; + private config: TerminalControllerConfig; + private callbacks: TerminalControllerCallbacks; + private pendingCommands: Map = new Map(); + + constructor( + config: TerminalControllerConfig = {}, + callbacks: TerminalControllerCallbacks = {}, + ) { + this.config = { + requireApproval: true, + timeout: 30000, + ...config, + }; + this.callbacks = callbacks; + } + + /** + * 连接到终端 + */ + connect(sessionId: string, termWrap?: TermWrap): void { + this.sessionId = sessionId; + this.termWrap = termWrap || null; + console.log("[TerminalController] 已连接到终端:", sessionId); + } + + /** + * 断开终端连接 + */ + disconnect(): void { + this.sessionId = null; + this.termWrap = null; + this.pendingCommands.clear(); + console.log("[TerminalController] 已断开终端连接"); + } + + /** + * 检查是否已连接 + */ + isConnected(): boolean { + return this.sessionId !== null; + } + + /** + * 获取当前会话 ID + */ + getSessionId(): string | null { + return this.sessionId; + } + + /** + * 发送命令到终端 + * + * 如果配置了 requireApproval,命令会先进入待审批状态。 + * + * @param command - 要执行的命令 + * @param autoExecute - 是否自动执行(跳过审批) + * @returns 命令 ID + */ + async sendCommand( + command: string, + autoExecute: boolean = false, + ): Promise { + if (!this.sessionId) { + throw new Error("终端未连接"); + } + + const pendingCommand: PendingCommand = { + id: crypto.randomUUID(), + command, + status: "pending", + createdAt: new Date(), + }; + + this.pendingCommands.set(pendingCommand.id, pendingCommand); + + if (this.config.requireApproval && !autoExecute) { + // 需要用户审批 + this.callbacks.onCommandPending?.(pendingCommand); + return pendingCommand.id; + } + + // 直接执行 + await this.executeCommand(pendingCommand.id); + return pendingCommand.id; + } + + /** + * 批准并执行命令 + */ + async approveCommand(commandId: string): Promise { + const command = this.pendingCommands.get(commandId); + if (!command) { + throw new Error(`命令不存在: ${commandId}`); + } + + if (command.status !== "pending") { + throw new Error(`命令状态无效: ${command.status}`); + } + + command.status = "approved"; + this.callbacks.onCommandStatusChange?.(command); + + await this.executeCommand(commandId); + } + + /** + * 拒绝命令 + */ + rejectCommand(commandId: string): void { + const command = this.pendingCommands.get(commandId); + if (!command) { + return; + } + + command.status = "rejected"; + this.callbacks.onCommandStatusChange?.(command); + this.pendingCommands.delete(commandId); + } + + /** + * 执行命令 + */ + private async executeCommand(commandId: string): Promise { + const command = this.pendingCommands.get(commandId); + if (!command || !this.sessionId) { + return; + } + + command.status = "executing"; + command.executedAt = new Date(); + this.callbacks.onCommandStatusChange?.(command); + + try { + // 发送命令到终端(添加换行符执行) + const commandWithNewline = command.command.endsWith("\n") + ? command.command + : command.command + "\n"; + + await writeToTerminal(this.sessionId, commandWithNewline); + + command.status = "completed"; + command.completedAt = new Date(); + this.callbacks.onCommandComplete?.(command); + } catch (error) { + command.status = "failed"; + command.error = error instanceof Error ? error.message : String(error); + this.callbacks.onCommandStatusChange?.(command); + } + } + + /** + * 直接发送数据到终端(不添加换行符) + * + * 用于发送特殊按键、控制序列等。 + */ + async sendData(data: string): Promise { + if (!this.sessionId) { + throw new Error("终端未连接"); + } + + await writeToTerminal(this.sessionId, data); + } + + /** + * 发送 Ctrl+C 中断信号 + */ + async sendInterrupt(): Promise { + await this.sendData("\x03"); + } + + /** + * 发送 Ctrl+D EOF 信号 + */ + async sendEOF(): Promise { + await this.sendData("\x04"); + } + + /** + * 获取终端输出(用于 AI 上下文) + */ + getTerminalOutput(): string | null { + if (!this.termWrap) { + return null; + } + + // 从 xterm.js 获取缓冲区内容 + const terminal = this.termWrap.terminal; + if (!terminal) { + return null; + } + + const buffer = terminal.buffer.active; + const lines: string[] = []; + + // 获取所有行 + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) { + lines.push(line.translateToString(true)); + } + } + + return lines.join("\n"); + } + + /** + * 获取待审批的命令列表 + */ + getPendingCommands(): PendingCommand[] { + return Array.from(this.pendingCommands.values()).filter( + (cmd) => cmd.status === "pending", + ); + } + + /** + * 清除所有待审批命令 + */ + clearPendingCommands(): void { + for (const [id, command] of this.pendingCommands) { + if (command.status === "pending") { + command.status = "rejected"; + this.callbacks.onCommandStatusChange?.(command); + this.pendingCommands.delete(id); + } + } + } +} + +// 全局终端控制器实例(单例) +let globalController: TerminalController | null = null; + +/** + * 获取全局终端控制器 + */ +export function getTerminalController(): TerminalController { + if (!globalController) { + globalController = new TerminalController(); + } + return globalController; +} + +/** + * 重置全局终端控制器 + */ +export function resetTerminalController(): void { + if (globalController) { + globalController.disconnect(); + globalController = null; + } +} diff --git a/src/components/terminal/ai/index.ts b/src/components/terminal/ai/index.ts index bc658232b..202362e38 100644 --- a/src/components/terminal/ai/index.ts +++ b/src/components/terminal/ai/index.ts @@ -11,3 +11,13 @@ export { TerminalAIInput } from "./TerminalAIInput"; export { TerminalAIMessages } from "./TerminalAIMessages"; export { TerminalAIModeSelector } from "./TerminalAIModeSelector"; export { TerminalAIWelcome } from "./TerminalAIWelcome"; +export { CommandApproval, CommandApprovalList } from "./CommandApproval"; +export { + TerminalController, + getTerminalController, + resetTerminalController, + type TerminalControllerConfig, + type TerminalControllerCallbacks, + type PendingCommand, + type CommandExecutionStatus, +} from "./TerminalController"; diff --git a/src/components/terminal/ai/types.ts b/src/components/terminal/ai/types.ts index eaee19b08..e38db0cfa 100644 --- a/src/components/terminal/ai/types.ts +++ b/src/components/terminal/ai/types.ts @@ -47,6 +47,8 @@ export interface TerminalAIConfig { widgetContext: boolean; /** 上下文行数限制 */ contextLines: number; + /** 是否自动执行命令(不需要手动批准) */ + autoExecute: boolean; } /** @@ -68,6 +70,29 @@ export interface ModelSelection { modelId: string; } +/** + * 待执行命令(用于 AI 控制终端) + */ +export interface PendingTerminalCommand { + id: string; + command: string; + status: + | "pending" + | "approved" + | "rejected" + | "executing" + | "completed" + | "failed"; + createdAt: Date; + executedAt?: Date; + completedAt?: Date; + error?: string; + /** 工作目录(可选,来自后端请求) */ + workingDir?: string; + /** 超时时间(秒,来自后端请求) */ + timeoutSecs?: number; +} + /** * Terminal AI Hook 返回值 */ @@ -87,8 +112,18 @@ export interface UseTerminalAIReturn { sendMessage: (content: string, images?: AIMessageImage[]) => Promise; clearMessages: () => void; toggleWidgetContext: () => void; + toggleAutoExecute: () => void; setContextLines: (lines: number) => void; // 终端上下文 getTerminalContext: () => string | null; + + // 终端控制(新增) + isTerminalConnected: boolean; + pendingCommands: PendingTerminalCommand[]; + connectTerminal: (sessionId: string) => void; + disconnectTerminal: () => void; + sendCommandToTerminal: (command: string) => Promise; + approveCommand: (commandId: string) => Promise; + rejectCommand: (commandId: string) => Promise; } diff --git a/src/components/terminal/ai/useTerminalAI.ts b/src/components/terminal/ai/useTerminalAI.ts index 5fc848e5b..48577c2cc 100644 --- a/src/components/terminal/ai/useTerminalAI.ts +++ b/src/components/terminal/ai/useTerminalAI.ts @@ -3,10 +3,11 @@ * @description Terminal AI Hook - 管理 AI 聊天状态和操作 * @module components/terminal/ai/useTerminalAI * - * 复用 Agent 模块的 API,提供 Terminal 专用的 AI 聊天功能 + * 复用 Agent 模块的 API,提供 Terminal 专用的 AI 聊天功能。 + * 支持 AI 控制终端执行命令(参考 Waveterm)。 */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { toast } from "sonner"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { @@ -15,14 +16,20 @@ import { createAgentSession, sendAgentMessageStream, parseStreamEvent, + sendTerminalCommandResponse, + sendTermScrollbackResponse, type StreamEvent, + type TerminalCommandRequest, + type TermScrollbackRequest, } from "@/lib/api/agent"; +import { writeToTerminal } from "@/lib/terminal-api"; import type { AIMessage, AIMessageImage, AIContentPart, TerminalAIConfig, UseTerminalAIReturn, + PendingTerminalCommand, } from "./types"; // 存储键 @@ -37,6 +44,7 @@ const STORAGE_KEYS = { const DEFAULT_CONFIG: TerminalAIConfig = { widgetContext: true, contextLines: 50, + autoExecute: false, // 默认需要手动批准 }; /** @@ -93,6 +101,28 @@ export function useTerminalAI( // 会话 ID const [sessionId, setSessionId] = useState(null); + // 终端控制状态 + const [terminalSessionId, setTerminalSessionId] = useState( + null, + ); + const [pendingCommands, setPendingCommands] = useState< + PendingTerminalCommand[] + >([]); + const pendingCommandsRef = useRef>( + new Map(), + ); + + // 使用 ref 存储最新的 config,避免闭包问题 + const configRef = useRef(config); + useEffect(() => { + configRef.current = config; + }, [config]); + + // 使用 ref 存储 approveCommand 函数引用 + const approveCommandRef = useRef< + ((commandId: string) => Promise) | null + >(null); + // 持久化 useEffect(() => { savePersisted(STORAGE_KEYS.PROVIDER, providerId); @@ -387,6 +417,9 @@ export function useTerminalAI( media_type: img.mediaType, })); + // 如果已连接终端,启用 terminal_mode(使用 TerminalTool 替代 BashTool) + const useTerminalMode = terminalSessionId !== null; + await sendAgentMessageStream( messageContent, eventName, @@ -394,6 +427,7 @@ export function useTerminalAI( modelId, imagesToSend, providerId, + useTerminalMode, ); } catch (error) { console.error("[useTerminalAI] 发送消息失败:", error); @@ -405,7 +439,7 @@ export function useTerminalAI( } } }, - [ensureSession, getTerminalContext, modelId, providerId], + [ensureSession, getTerminalContext, modelId, providerId, terminalSessionId], ); /** @@ -437,6 +471,361 @@ export function useTerminalAI( })); }, []); + // ============================================================================ + // 终端控制功能 + // ============================================================================ + + /** + * 连接到终端 + */ + const connectTerminal = useCallback((sessionId: string) => { + setTerminalSessionId(sessionId); + console.log("[useTerminalAI] 已连接到终端:", sessionId); + }, []); + + /** + * 断开终端连接 + */ + const disconnectTerminal = useCallback(() => { + setTerminalSessionId(null); + setPendingCommands([]); + pendingCommandsRef.current.clear(); + console.log("[useTerminalAI] 已断开终端连接"); + }, []); + + /** + * 监听后端发送的终端命令请求 + */ + useEffect(() => { + if (!terminalSessionId) return; + + let unlisten: UnlistenFn | null = null; + + const setupListener = async () => { + unlisten = await listen( + "terminal_command_request", + (event) => { + const request = event.payload; + console.log("[useTerminalAI] 收到终端命令请求:", request); + + // 创建待审批命令 + const pendingCommand: PendingTerminalCommand = { + id: request.request_id, + command: request.command, + status: "pending", + createdAt: new Date(), + workingDir: request.working_dir, + timeoutSecs: request.timeout_secs, + }; + + pendingCommandsRef.current.set(pendingCommand.id, pendingCommand); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + + // 如果开启了自动执行,直接批准命令 + if (configRef.current.autoExecute) { + console.log("[useTerminalAI] 自动执行模式已启用,自动批准命令"); + // 使用 setTimeout 确保状态更新后再执行 + setTimeout(() => { + if (approveCommandRef.current) { + approveCommandRef.current(pendingCommand.id); + } + }, 0); + // 不显示 toast,避免遮挡终端输出 + console.log("[useTerminalAI] AI 命令自动执行:", request.command); + } else { + // 显示通知,需要手动批准(这个保留,因为需要用户注意) + toast.info("AI 请求执行命令,请审批", { + description: + request.command.slice(0, 50) + + (request.command.length > 50 ? "..." : ""), + }); + } + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [terminalSessionId]); + + /** + * 监听后端发送的终端滚动缓冲区请求 + */ + useEffect(() => { + if (!terminalSessionId) return; + + let unlisten: UnlistenFn | null = null; + + const setupListener = async () => { + unlisten = await listen( + "term_get_scrollback_request", + async (event) => { + const request = event.payload; + console.log("[useTerminalAI] 收到终端滚动缓冲区请求:", request); + + try { + // 获取终端输出 + const output = getTerminalOutput ? getTerminalOutput() : null; + + if (!output) { + // 没有输出 + await sendTermScrollbackResponse({ + request_id: request.request_id, + success: true, + total_lines: 0, + line_start: 0, + line_end: 0, + content: "", + has_more: false, + }); + return; + } + + // 分割成行 + const lines = output.split("\n"); + const totalLines = lines.length; + + // 计算实际的起始和结束行号 + const requestedStart = request.line_start ?? 0; + const requestedCount = request.count ?? totalLines; + + const actualStart = Math.max( + 0, + Math.min(requestedStart, totalLines - 1), + ); + const actualEnd = Math.min( + actualStart + requestedCount, + totalLines, + ); + + // 提取请求的行 + const requestedLines = lines.slice(actualStart, actualEnd); + const content = requestedLines.join("\n"); + + // 发送响应 + await sendTermScrollbackResponse({ + request_id: request.request_id, + success: true, + total_lines: totalLines, + line_start: actualStart, + line_end: actualEnd, + content, + has_more: actualEnd < totalLines, + }); + + console.log( + `[useTerminalAI] 已发送滚动缓冲区响应: ${actualStart}-${actualEnd}/${totalLines} 行`, + ); + } catch (error) { + console.error("[useTerminalAI] 处理滚动缓冲区请求失败:", error); + + // 发送错误响应 + await sendTermScrollbackResponse({ + request_id: request.request_id, + success: false, + total_lines: 0, + line_start: 0, + line_end: 0, + content: "", + has_more: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [terminalSessionId, getTerminalOutput]); + + /** + * 发送命令到终端(需要用户审批)- 本地创建的命令 + */ + const sendCommandToTerminal = useCallback( + async (command: string): Promise => { + if (!terminalSessionId) { + throw new Error("终端未连接"); + } + + const pendingCommand: PendingTerminalCommand = { + id: crypto.randomUUID(), + command, + status: "pending", + createdAt: new Date(), + }; + + pendingCommandsRef.current.set(pendingCommand.id, pendingCommand); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + + return pendingCommand.id; + }, + [terminalSessionId], + ); + + /** + * 批准并执行命令(内部实现) + */ + const approveCommandInternal = useCallback( + async (commandId: string): Promise => { + const command = pendingCommandsRef.current.get(commandId); + if (!command || !terminalSessionId) { + return; + } + + if (command.status !== "pending") { + return; + } + + // 更新状态为执行中 + command.status = "executing"; + command.executedAt = new Date(); + pendingCommandsRef.current.set(commandId, command); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + + try { + // 发送命令到终端(添加换行符执行) + const commandWithNewline = command.command.endsWith("\n") + ? command.command + : command.command + "\n"; + + await writeToTerminal(terminalSessionId, commandWithNewline); + + // 更新状态为完成 + command.status = "completed"; + command.completedAt = new Date(); + pendingCommandsRef.current.set(commandId, command); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + + // 发送响应给后端(告知 TerminalTool 命令已执行) + // 对于简单的 echo 命令,模拟输出以避免 AI 重复执行 + let simulatedOutput = ""; + const echoMatch = command.command.match(/^echo\s+(.+)$/i); + if (echoMatch) { + // 提取 echo 的内容(去除引号) + let echoContent = echoMatch[1]; + // 移除外层引号 + if ( + (echoContent.startsWith('"') && echoContent.endsWith('"')) || + (echoContent.startsWith("'") && echoContent.endsWith("'")) + ) { + echoContent = echoContent.slice(1, -1); + } + simulatedOutput = `${echoContent}`; + } + + // 构建明确的成功响应 + const successOutput = simulatedOutput + ? `[COMMAND EXECUTED SUCCESSFULLY]\nOutput:\n${simulatedOutput}\n[END OF OUTPUT]` + : `[COMMAND EXECUTED SUCCESSFULLY]\nThe command "${command.command}" has been executed in the user's terminal.\nNote: Output is displayed in the terminal window. Do NOT re-execute this command.\n[END OF OUTPUT]`; + + await sendTerminalCommandResponse({ + request_id: commandId, + success: true, + output: successOutput, + rejected: false, + }); + + // 不显示 toast,避免遮挡终端输出 + // 命令执行状态已经在 AI 面板中显示 + console.log("[useTerminalAI] 命令已执行:", command.command); + + // 延迟移除已完成的命令 + setTimeout(() => { + pendingCommandsRef.current.delete(commandId); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + }, 2000); + } catch (error) { + command.status = "failed"; + command.error = error instanceof Error ? error.message : String(error); + pendingCommandsRef.current.set(commandId, command); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + + // 发送失败响应给后端 + await sendTerminalCommandResponse({ + request_id: commandId, + success: false, + output: "", + error: command.error, + rejected: false, + }); + + toast.error(`命令执行失败: ${command.error}`); + } + }, + [terminalSessionId], + ); + + // 更新 ref + useEffect(() => { + approveCommandRef.current = approveCommandInternal; + }, [approveCommandInternal]); + + /** + * 批准并执行命令 + */ + const approveCommand = useCallback( + async (commandId: string): Promise => { + await approveCommandInternal(commandId); + }, + [approveCommandInternal], + ); + + /** + * 拒绝命令 + */ + const rejectCommand = useCallback( + async (commandId: string): Promise => { + const command = pendingCommandsRef.current.get(commandId); + if (!command) { + return; + } + + command.status = "rejected"; + pendingCommandsRef.current.set(commandId, command); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + + // 发送拒绝响应给后端 + await sendTerminalCommandResponse({ + request_id: commandId, + success: false, + output: "", + error: "用户拒绝执行此命令", + rejected: true, + }); + + toast.info("命令已拒绝"); + + // 延迟移除 + setTimeout(() => { + pendingCommandsRef.current.delete(commandId); + setPendingCommands(Array.from(pendingCommandsRef.current.values())); + }, 1000); + }, + [], + ); + + /** + * 切换自动执行模式 + */ + const toggleAutoExecute = useCallback(() => { + setConfig((prev) => { + const newConfig = { ...prev, autoExecute: !prev.autoExecute }; + savePersisted(STORAGE_KEYS.CONFIG, newConfig); + return newConfig; + }); + }, []); + return { messages, isSending, @@ -448,7 +837,16 @@ export function useTerminalAI( sendMessage, clearMessages, toggleWidgetContext, + toggleAutoExecute, setContextLines, getTerminalContext, + // 终端控制 + isTerminalConnected: terminalSessionId !== null, + pendingCommands, + connectTerminal, + disconnectTerminal, + sendCommandToTerminal, + approveCommand, + rejectCommand, }; } diff --git a/src/components/tools/ToolsPage.tsx b/src/components/tools/ToolsPage.tsx index d37f1f57e..b0bbe3359 100644 --- a/src/components/tools/ToolsPage.tsx +++ b/src/components/tools/ToolsPage.tsx @@ -215,14 +215,6 @@ const placeholderTools: DynamicToolCard[] = [ * 推荐插件列表 */ const recommendedPlugins: RecommendedPlugin[] = [ - { - id: "terminal-plugin", - name: "终端", - description: "本地 PTY 和 SSH 终端模拟器,支持多标签页和搜索功能", - icon: "Terminal", - downloadUrl: - "https://github.com/aiclientproxy/terminal/releases/latest/download/terminal-plugin.zip", - }, { id: "machine-id-tool", name: "机器码管理工具", diff --git a/src/hooks/useModelRegistry.ts b/src/hooks/useModelRegistry.ts index a7cd31228..fcab0016a 100644 --- a/src/hooks/useModelRegistry.ts +++ b/src/hooks/useModelRegistry.ts @@ -164,35 +164,55 @@ export function useModelRegistry( const [error, setError] = useState(null); const [lastSyncAt, setLastSyncAt] = useState(null); - // 加载模型数据 + // 加载模型数据(带重试机制) const loadModels = useCallback(async () => { setLoading(true); setError(null); - try { - const [models, prefs, syncState] = await Promise.all([ - modelRegistryApi.getModelRegistry(), - modelRegistryApi.getModelPreferences(), - modelRegistryApi.getModelSyncState(), - ]); + const maxRetries = 5; + const retryDelay = 500; // 500ms - setAllModels(models); - setPreferences(new Map(prefs.map((p) => [p.model_id, p]))); - setLastSyncAt(syncState.last_sync_at); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const [models, prefs, syncState] = await Promise.all([ + modelRegistryApi.getModelRegistry(), + modelRegistryApi.getModelPreferences(), + modelRegistryApi.getModelSyncState(), + ]); + + setAllModels(models); + setPreferences(new Map(prefs.map((p) => [p.model_id, p]))); + setLastSyncAt(syncState.last_sync_at); + setLoading(false); + return; // 成功,退出 + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + + // 如果是"服务未初始化"错误,且还有重试次数,则等待后重试 + if (errorMsg.includes("未初始化") && attempt < maxRetries - 1) { + console.log( + `[ModelRegistry] 服务未初始化,${retryDelay}ms 后重试 (${attempt + 1}/${maxRetries})`, + ); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + continue; + } + + // 其他错误或已达到最大重试次数 + setError(errorMsg); + setLoading(false); + return; + } } }, []); - // 刷新(强制从 models.dev 获取) + // 刷新(强制从内嵌资源重新加载) const refresh = useCallback(async () => { setLoading(true); setError(null); try { - await modelRegistryApi.refreshModelRegistry(); + const count = await modelRegistryApi.refreshModelRegistry(); + console.log(`[ModelRegistry] 刷新完成,加载了 ${count} 个模型`); await loadModels(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index b972d165b..821feeba4 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -132,6 +132,26 @@ export interface ApiKeyEntry { proxy_url: string | null; } +// ============ 实验室功能配置 ============ + +/** + * 截图对话功能配置 + */ +export interface ScreenshotChatConfig { + /** 是否启用截图对话功能 */ + enabled: boolean; + /** 触发截图的全局快捷键 */ + shortcut: string; +} + +/** + * 实验室功能配置 + */ +export interface ExperimentalFeatures { + /** 截图对话功能配置 */ + screenshot_chat: ScreenshotChatConfig; +} + export interface Config { server: { host: string; @@ -174,6 +194,8 @@ export interface Config { minimize_to_tray: boolean; /** 用户界面语言 ("zh" 或 "en") */ language: string; + /** 实验室功能配置 */ + experimental?: ExperimentalFeatures; } export interface LogEntry { @@ -511,3 +533,44 @@ export interface NetworkInfo { export async function getNetworkInfo(): Promise { return safeInvoke("get_network_info"); } + +// ============ 实验室功能 API ============ + +/** + * 获取实验室功能配置 + * @returns 实验室功能配置对象 + */ +export async function getExperimentalConfig(): Promise { + return safeInvoke("get_experimental_config"); +} + +/** + * 保存实验室功能配置 + * @param config 实验室功能配置对象 + */ +export async function saveExperimentalConfig( + config: ExperimentalFeatures, +): Promise { + return safeInvoke("save_experimental_config", { + experimentalConfig: config, + }); +} + +/** + * 验证快捷键格式 + * @param shortcut 快捷键字符串 + * @returns 是否有效 + */ +export async function validateShortcut(shortcut: string): Promise { + return safeInvoke("validate_shortcut", { shortcutStr: shortcut }); +} + +/** + * 更新截图快捷键 + * @param shortcut 新的快捷键字符串 + */ +export async function updateScreenshotShortcut( + shortcut: string, +): Promise { + return safeInvoke("update_screenshot_shortcut", { newShortcut: shortcut }); +} diff --git a/src/lib/api/agent.ts b/src/lib/api/agent.ts index 39c49d0d5..3301a3a40 100644 --- a/src/lib/api/agent.ts +++ b/src/lib/api/agent.ts @@ -313,6 +313,7 @@ export async function sendAgentMessageStream( model?: string, images?: ImageInput[], provider?: string, + terminalMode?: boolean, ): Promise { return await invoke("native_agent_chat_stream", { message, @@ -321,6 +322,7 @@ export async function sendAgentMessageStream( model, images, provider, + terminalMode, }); } @@ -450,3 +452,117 @@ export async function extendGooseSystemPrompt( export async function listGooseProviders(): Promise { return await invoke("goose_agent_list_providers"); } + +// ============================================================ +// Terminal Tool API (终端命令执行) +// ============================================================ + +/** + * 终端命令请求(从后端发送到前端) + */ +export interface TerminalCommandRequest { + /** 请求 ID */ + request_id: string; + /** 要执行的命令 */ + command: string; + /** 工作目录(可选) */ + working_dir?: string; + /** 超时时间(秒) */ + timeout_secs: number; +} + +/** + * 终端命令响应(从前端发送到后端) + */ +export interface TerminalCommandResponse { + /** 请求 ID */ + request_id: string; + /** 是否成功 */ + success: boolean; + /** 输出内容 */ + output: string; + /** 错误信息 */ + error?: string; + /** 退出码 */ + exit_code?: number; + /** 是否被用户拒绝 */ + rejected: boolean; +} + +/** + * 发送终端命令响应到后端 + * + * 当用户批准或拒绝命令后,调用此函数将结果发送给 TerminalTool + */ +export async function sendTerminalCommandResponse( + response: TerminalCommandResponse, +): Promise { + return await invoke("agent_terminal_command_response", { + requestId: response.request_id, + success: response.success, + output: response.output, + error: response.error, + exitCode: response.exit_code, + rejected: response.rejected, + }); +} + +// ============================================================ +// Terminal Scrollback Tool API (终端输出历史读取) +// ============================================================ + +/** + * 终端滚动缓冲区请求(从后端发送到前端) + */ +export interface TermScrollbackRequest { + /** 请求 ID */ + request_id: string; + /** 终端会话 ID */ + session_id: string; + /** 起始行号(可选,从 0 开始) */ + line_start?: number; + /** 读取行数(可选) */ + count?: number; +} + +/** + * 终端滚动缓冲区响应(从前端发送到后端) + */ +export interface TermScrollbackResponse { + /** 请求 ID */ + request_id: string; + /** 是否成功 */ + success: boolean; + /** 总行数 */ + total_lines: number; + /** 实际返回的起始行号 */ + line_start: number; + /** 实际返回的结束行号 */ + line_end: number; + /** 输出内容 */ + content: string; + /** 是否还有更多内容 */ + has_more: boolean; + /** 错误信息 */ + error?: string; +} + +/** + * 发送终端滚动缓冲区响应到后端 + * + * 当前端读取终端输出历史后,调用此函数将结果发送给 TermScrollbackTool + */ +export async function sendTermScrollbackResponse( + response: TermScrollbackResponse, +): Promise { + return await invoke("agent_term_scrollback_response", { + requestId: response.request_id, + success: response.success, + totalLines: response.total_lines, + lineStart: response.line_start, + lineEnd: response.line_end, + content: response.content, + hasMore: response.has_more, + error: response.error, + }); +} diff --git a/src/lib/api/modelRegistry.ts b/src/lib/api/modelRegistry.ts index 31a87549c..4c56cfaa8 100644 --- a/src/lib/api/modelRegistry.ts +++ b/src/lib/api/modelRegistry.ts @@ -21,9 +21,10 @@ export async function getModelRegistry(): Promise { } /** - * 刷新模型注册表(从 models.dev 获取最新数据) + * 刷新模型注册表(强制从内嵌资源重新加载) + * @returns 加载的模型数量 */ -export async function refreshModelRegistry(): Promise { +export async function refreshModelRegistry(): Promise { return invoke("refresh_model_registry"); } diff --git a/src/lib/plugin-loader/index.ts b/src/lib/plugin-loader/index.ts index a4be0f579..4f71022bf 100644 --- a/src/lib/plugin-loader/index.ts +++ b/src/lib/plugin-loader/index.ts @@ -34,6 +34,7 @@ const loadedPlugins = new Map(); /** * 插件 ID 到全局变量名的映射 * 格式: pluginId -> GlobalVariableName + * 注意: terminal-plugin 已移除,终端功能已内置到应用中 */ const PLUGIN_GLOBAL_NAMES: Record = { "kiro-provider": "KiroProviderPlugin", @@ -42,7 +43,6 @@ const PLUGIN_GLOBAL_NAMES: Record = { "gemini-provider": "GeminiProviderPlugin", "antigravity-provider": "AntigravityProviderPlugin", "codex-provider": "CodexProviderPlugin", - "terminal-plugin": "TerminalPlugin", }; /** diff --git a/src/main.tsx b/src/main.tsx index 8becbdec0..feef293bb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,5 @@ -import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App"; -import { Toaster } from "./components/ui/sonner"; +import { RootRouter } from "./RootRouter"; import "./index.css"; // Initialize Tauri mock for web mode @@ -13,9 +11,4 @@ import "./i18n/config"; // 初始化插件组件全局暴露(供动态加载的插件使用) import "./lib/plugin-components/global"; -ReactDOM.createRoot(document.getElementById("root")!).render( - <> - - - , -); +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/src/pages/README.md b/src/pages/README.md index ad6edb09d..05fa60ed3 100644 --- a/src/pages/README.md +++ b/src/pages/README.md @@ -10,6 +10,7 @@ ## 文件索引 - `FlowMonitorPage.tsx` - LLM 流量监控页面 +- `screenshot-chat.tsx` - 截图对话悬浮窗口页面(独立 Tauri 窗口) - `index.ts` - 页面导出入口 ## 更新提醒 diff --git a/src/pages/index.ts b/src/pages/index.ts index e6ba26063..e7c917e36 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1 +1,2 @@ export { FlowMonitorPage } from "./FlowMonitorPage"; +export { ScreenshotChatPage } from "./screenshot-chat"; diff --git a/src/pages/screenshot-chat.css b/src/pages/screenshot-chat.css new file mode 100644 index 000000000..62fc73dc8 --- /dev/null +++ b/src/pages/screenshot-chat.css @@ -0,0 +1,293 @@ +/** + * @file screenshot-chat.css + * @description 截图对话输入框样式 - 参考 Google Gemini 浮动栏设计 + * 半透明药丸形状,毛玻璃效果 + */ + +/* 全局重置 */ +html, +body, +#root { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: transparent !important; + overflow: hidden; +} + +/* 容器 - 增加 padding 来容纳阴影 */ +.screenshot-container { + width: 100%; + height: 100%; + padding: 10px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + background: transparent !important; +} + +/* 输入框容器 - Gemini 风格半透明药丸 */ +.screenshot-input-bar { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 48px; + padding: 0 12px; + /* 半透明毛玻璃效果 */ + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + /* 完全药丸形状 - 高度的一半 */ + border-radius: 24px; + /* 柔和阴影 */ + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.08), + 0 4px 24px rgba(0, 0, 0, 0.12), + inset 0 0 0 1px rgba(255, 255, 255, 0.5); + box-sizing: border-box; + transition: all 0.2s ease; +} + +.screenshot-input-bar:focus-within { + background: rgba(255, 255, 255, 0.95); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.1), + 0 8px 32px rgba(0, 0, 0, 0.15), + inset 0 0 0 1px rgba(255, 255, 255, 0.6); +} + +/* 拖动手柄 */ +.screenshot-drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 32px; + color: #9ca3af; + cursor: grab; + flex-shrink: 0; + border-radius: 4px; + transition: all 0.15s ease; +} + +.screenshot-drag-handle:hover { + color: #6b7280; + background: rgba(0, 0, 0, 0.05); +} + +.screenshot-drag-handle:active { + cursor: grabbing; + color: #374151; +} + +/* Logo */ +.screenshot-logo { + flex-shrink: 0; + opacity: 0.9; +} + +/* 图片附件标签 - 更紧凑的药丸样式 */ +.screenshot-attachment { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px 4px 6px; + background: rgba(124, 77, 255, 0.1); + border: 1px solid rgba(124, 77, 255, 0.2); + border-radius: 14px; + color: #5c3dbd; + font-size: 11px; + font-weight: 500; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.screenshot-attachment:hover { + background: rgba(124, 77, 255, 0.15); +} + +.screenshot-attachment svg:first-child { + color: #7c4dff; + width: 12px; + height: 12px; +} + +.screenshot-attachment-remove { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-left: 2px; + padding: 0; + background: transparent; + border: none; + border-radius: 50%; + color: #9ca3af; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-attachment-remove:hover { + background: rgba(124, 77, 255, 0.2); + color: #5c3dbd; +} + +/* 输入框 */ +.screenshot-input { + flex: 1; + min-width: 0; + padding: 6px 0; + border: none; + background: transparent; + font-size: 14px; + color: #1f2937; + outline: none; +} + +.screenshot-input::placeholder { + color: #9ca3af; +} + +.screenshot-input:disabled { + opacity: 0.6; +} + +/* 右侧按钮组 */ +.screenshot-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +/* 关闭按钮 */ +.screenshot-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 50%; + color: #9ca3af; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-close-btn:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +/* 工具按钮(+号菜单风格) */ +.screenshot-tools-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: none; + border-radius: 50%; + color: #6b7280; + cursor: pointer; + transition: all 0.15s ease; +} + +.screenshot-tools-btn:hover { + background: rgba(0, 0, 0, 0.05); + color: #374151; +} + +/* 发送按钮 - Gemini 风格圆形 */ +.screenshot-send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #e5e7eb; + border: none; + border-radius: 50%; + color: #9ca3af; + cursor: not-allowed; + transition: all 0.2s ease; +} + +.screenshot-send-btn.active { + background: linear-gradient(135deg, #7c4dff 0%, #536dfe 100%); + color: white; + cursor: pointer; + box-shadow: 0 2px 8px rgba(124, 77, 255, 0.3); +} + +.screenshot-send-btn.active:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(124, 77, 255, 0.4); +} + +/* 暗色模式支持 */ +@media (prefers-color-scheme: dark) { + .screenshot-input-bar { + background: rgba(30, 30, 30, 0.85); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.2), + 0 4px 24px rgba(0, 0, 0, 0.3), + inset 0 0 0 1px rgba(255, 255, 255, 0.1); + } + + .screenshot-input-bar:focus-within { + background: rgba(40, 40, 40, 0.95); + } + + .screenshot-drag-handle { + color: #6b7280; + } + + .screenshot-drag-handle:hover { + color: #9ca3af; + background: rgba(255, 255, 255, 0.1); + } + + .screenshot-input { + color: #f3f4f6; + } + + .screenshot-input::placeholder { + color: #6b7280; + } + + .screenshot-attachment { + background: rgba(124, 77, 255, 0.2); + border-color: rgba(124, 77, 255, 0.3); + color: #a78bfa; + } + + .screenshot-close-btn { + color: #6b7280; + } + + .screenshot-close-btn:hover { + background: rgba(239, 68, 68, 0.2); + color: #f87171; + } + + .screenshot-tools-btn { + color: #9ca3af; + } + + .screenshot-tools-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #e5e7eb; + } + + .screenshot-send-btn { + background: #374151; + color: #6b7280; + } +} diff --git a/src/pages/screenshot-chat.tsx b/src/pages/screenshot-chat.tsx new file mode 100644 index 000000000..5a9a4432a --- /dev/null +++ b/src/pages/screenshot-chat.tsx @@ -0,0 +1,197 @@ +/** + * @file screenshot-chat.tsx + * @description 截图对话悬浮窗口 - 参考 Google Gemini 浮动栏设计 + * 半透明药丸形状,简洁的输入界面 + * @module pages/screenshot-chat + */ + +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { Image as ImageIcon, ArrowUp, X, GripVertical } from "lucide-react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import "./screenshot-chat.css"; + +// ProxyCast Logo组件 +function Logo() { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function getImagePathFromUrl(): string | null { + const params = new URLSearchParams(window.location.search); + const imagePath = params.get("image"); + return imagePath ? decodeURIComponent(imagePath) : null; +} + +export function ScreenshotChatPage() { + const [imagePath, setImagePath] = useState(null); + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + + // 从 URL 获取图片路径 + useEffect(() => { + const path = getImagePathFromUrl(); + if (path) { + setImagePath(path); + } + }, []); + + // 自动聚焦 + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // 关闭窗口 + const handleClose = useCallback(async () => { + try { + await getCurrentWindow().close(); + } catch (err) { + console.error("关闭窗口失败:", err); + } + }, []); + + // ESC 关闭窗口 + useEffect(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + if (e.key === "Escape") { + await handleClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleClose]); + + // 开始拖动窗口 + const handleStartDrag = useCallback(async (e: React.MouseEvent) => { + // 只响应左键 + if (e.button !== 0) return; + try { + await getCurrentWindow().startDragging(); + } catch (err) { + console.error("拖动窗口失败:", err); + } + }, []); + + // 移除图片附件 + const handleRemoveImage = () => { + setImagePath(null); + }; + + // 发送到主应用 + const handleSend = async () => { + if (!inputValue.trim() || isLoading) return; + setIsLoading(true); + + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("send_screenshot_chat", { + message: inputValue, + imagePath: imagePath, + }); + + await getCurrentWindow().close(); + } catch (err) { + console.error("发送失败:", err); + setIsLoading(false); + } + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+
+ {/* 拖动手柄 */} +
+ +
+ + {/* Logo */} + + + {/* 图片附件标签 */} + {imagePath && ( +
+ + Image + +
+ )} + + {/* 输入框 */} + setInputValue(e.target.value)} + onKeyDown={handleInputKeyDown} + disabled={isLoading} + /> + + {/* 右侧按钮组 */} +
+ {/* 关闭按钮 */} + + + {/* 发送按钮 */} + +
+
+
+ ); +} + +export default ScreenshotChatPage; diff --git a/sync-resources.sh b/sync-resources.sh new file mode 100755 index 000000000..304ff21cd --- /dev/null +++ b/sync-resources.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# 同步资源文件到 target/debug 目录 + +echo "正在同步资源文件..." + +# 创建目标目录 +mkdir -p src-tauri/target/debug/resources/models/providers +mkdir -p src-tauri/target/debug/resources/models/aliases + +# 复制模型文件 +cp -v src-tauri/resources/models/index.json src-tauri/target/debug/resources/models/ +cp -v src-tauri/resources/models/providers/*.json src-tauri/target/debug/resources/models/providers/ +cp -v src-tauri/resources/models/aliases/*.json src-tauri/target/debug/resources/models/aliases/ 2>/dev/null || true + +echo "✅ 资源文件同步完成!"