feat: v0.37.0 - Terminal AI integration and Screenshot Chat

## Terminal AI Features
- Add Terminal AI panel with command execution support
- Add term_get_scrollback tool for reading terminal output history
- Add terminal tool with command approval workflow
- Add auto-execute mode for trusted commands
- Add duplicate command detection to prevent AI loops
- Improve command response format with clear success markers

## Screenshot Chat (Experimental)
- Add screenshot chat window for quick AI conversations
- Add global shortcut support for screenshot capture
- Add experimental settings page

## Code Quality
- Fix all compiler warnings with proper analysis
- Remove unused imports and dead code
- Add #[allow(dead_code)] for reserved/planned features
- Remove deprecated terminal-plugin from recommendations

## Other Changes
- Update model registry with new capabilities
- Improve config observer system
- Add webview panel position fields (reserved)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-09 10:05:54 +08:00
co-authored by Claude Opus 4.5
parent 9dbab22624
commit db8e916e90
87 changed files with 8069 additions and 469 deletions
+218
View File
@@ -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/)
+2 -1
View File
@@ -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",
View File
+98 -6
View File
@@ -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"
+5 -2
View File
@@ -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
@@ -1 +0,0 @@
/Users/coso/Documents/dev/ai/astercloud/aster/aster-server
+12 -1
View File
@@ -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": [
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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}]}]}}
{"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}]}]}}
+120
View File
@@ -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",
+66
View File
@@ -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",
+17 -2
View File
@@ -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<Arc<ToolRegistry>, 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<Arc<ToolRegistry>, 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))
}
+79
View File
@@ -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<Path>) -> 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<Path>) -> 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
}
@@ -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<usize>,
/// 行数(可选,默认 200 行)
pub count: Option<usize>,
}
/// 获取滚动缓冲区的响应
#[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<String>,
}
/// 待处理的请求(内部使用)
pub(crate) struct PendingRequest {
/// 响应发送器
response_tx: oneshot::Sender<GetScrollbackResponse>,
}
/// Terminal Scrollback 工具
///
/// 允许 AI 读取终端的输出历史,而不是直接执行命令
pub struct TermScrollbackTool {
/// 待处理的请求
pending_requests: Arc<RwLock<HashMap<String, PendingRequest>>>,
/// 默认超时时间(秒)
timeout_secs: u64,
/// Tauri AppHandle(用于发送事件)
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
}
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<usize>,
count: Option<usize>,
) -> Result<GetScrollbackResponse, ToolError> {
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<ToolResult, ToolError> {
// 解析参数
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<Arc<TermScrollbackTool>> =
once_cell::sync::Lazy::new(|| {
eprintln!("[TermScrollbackTool] 创建全局实例");
Arc::new(TermScrollbackTool::new())
});
/// 获取全局 TermScrollbackTool 实例
pub fn get_term_scrollback_tool() -> Arc<TermScrollbackTool> {
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);
}
+499
View File
@@ -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<String>,
/// 超时时间(秒)
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<String>,
/// 退出码
pub exit_code: Option<i32>,
/// 是否被用户拒绝
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<TerminalCommandResponse>,
}
/// 已执行命令的记录
#[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<RwLock<HashMap<String, PendingCommand>>>,
/// 已执行的命令历史(用于检测重复)
executed_commands: Arc<RwLock<Vec<ExecutedCommand>>>,
/// 默认超时时间(秒)
timeout_secs: u64,
/// Tauri AppHandle(用于发送事件)
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
}
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<TerminalCommandResponse, ToolError> {
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<ToolResult, ToolError> {
// 解析参数
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<Arc<TerminalTool>> = once_cell::sync::Lazy::new(|| {
eprintln!("[TerminalTool] 创建全局实例");
Arc::new(TerminalTool::new())
});
/// 获取全局 TerminalTool 实例
pub fn get_terminal_tool() -> Arc<TerminalTool> {
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);
}
}
+70 -3
View File
@@ -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");
+9
View File
@@ -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);
@@ -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,
+73
View File
@@ -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<String>,
exit_code: Option<i32>,
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<String>,
) -> 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(())
}
+1
View File
@@ -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;
@@ -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<u32, String> {
let guard = state.read().await;
let service = guard
.as_ref()
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
service.force_reload().await
}
+7 -3
View File
@@ -160,14 +160,17 @@ pub async fn native_agent_chat_stream(
model: Option<String>,
images: Option<Vec<ImageInputParam>>,
provider: Option<String>,
terminal_mode: Option<bool>,
) -> 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 以保持上下文
+352
View File
@@ -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<ExperimentalFeatures, String> {
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<String, String> {
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<bool, String> {
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<String, String> {
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<String>,
pub image_base64: Option<String>,
}
/// 发送截图对话消息到主应用
///
/// 将用户输入的消息和截图发送到主应用的 Agent 聊天界面
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `message`: 用户输入的消息
/// - `image_path`: 截图文件路径(可选)
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
#[tauri::command]
pub async fn send_screenshot_chat(
app: AppHandle,
message: String,
image_path: Option<String>,
) -> 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());
}
}
+4 -2
View File
@@ -65,9 +65,11 @@ pub struct CreateWebviewRequest {
pub url: String,
/// 面板标题
pub title: Option<String>,
/// X 坐标(相对于主窗口)
/// X 坐标(相对于主窗口)- 预留,当前使用居中显示
#[allow(dead_code)]
pub x: f64,
/// Y 坐标(相对于主窗口)
/// Y 坐标(相对于主窗口)- 预留,当前使用居中显示
#[allow(dead_code)]
pub y: f64,
/// 宽度
pub width: f64,
+6 -5
View File
@@ -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};
+2
View File
@@ -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";
/// 观察者条目
+4
View File
@@ -95,6 +95,8 @@ impl<T: SyncConfigObserver + 'static> ConfigObserver for SyncObserverWrapper<T>
}
/// 函数式观察者(用于简单的回调场景)
/// 目前主要用于测试,将来可用于动态注册观察者
#[allow(dead_code)]
pub struct FnObserver<F>
where
F: Fn(&ConfigChangeEvent, &Config) -> Result<(), String> + Send + Sync,
@@ -108,6 +110,7 @@ impl<F> FnObserver<F>
where
F: Fn(&ConfigChangeEvent, &Config) -> Result<(), String> + Send + Sync,
{
#[allow(dead_code)]
pub fn new(name: impl Into<String>, 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
+3
View File
@@ -188,6 +188,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
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<Value = Config> {
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<Value = Config> {
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
};
// 根据类型使配置无效
match invalid_type {
+83
View File
@@ -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"
);
}
}
+1
View File
@@ -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;
+4
View File
@@ -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();
+7 -5
View File
@@ -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<Vec<CredentialInfo>>,
@@ -313,6 +314,7 @@ pub fn get_global_orchestrator() -> Option<Arc<ModelOrchestrator>> {
#[cfg(test)]
mod tests {
use super::super::pool_builder::ProviderType;
use super::*;
#[tokio::test]
+3 -4
View File
@@ -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
}
+69
View File
@@ -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: 模块化代码组织
+247
View File
@@ -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<PathBuf, CaptureError> {
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"));
}
}
+132
View File
@@ -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<ExperimentalFeatures, ConfigError> {
debug!("加载实验室功能配置");
// 获取全局配置管理器
let config_manager = app
.try_state::<GlobalConfigManagerState>()
.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<ScreenshotChatConfig, ConfigError> {
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::<GlobalConfigManagerState>()
.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);
}
}
+69
View File
@@ -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(())
}
+322
View File
@@ -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<parking_lot::RwLock<Option<String>>> = OnceLock::new();
/// 快捷键是否已注册
static IS_REGISTERED: AtomicBool = AtomicBool::new(false);
fn get_current_shortcut() -> &'static parking_lot::RwLock<Option<String>> {
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::<Shortcut>().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<String> {
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());
}
}
+300
View File
@@ -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<tauri::Monitor> {
// 使用 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");
}
}
+129 -192
View File
@@ -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::<ProviderType>()
.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::<ProviderType>()
.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<crate::models::provider_pool_model::ProviderCredential> =
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::<ProviderType>()
.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::<ProviderType>()
.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())
@@ -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::<serde_json::Value>(&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()
}
+140 -29
View File
@@ -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::<crate::ProviderType>() {
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::<crate::ProviderType>() {
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::<crate::ProviderType>()
{
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::<crate::ProviderType>() {
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::<crate::ProviderType>() {
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;
+111 -53
View File
@@ -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<String, usize> =
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<Vec<EnhancedModelMetadata>, 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<u32, String> {
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<EnhancedModelMetadata> {
self.models_cache
@@ -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() {
@@ -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 序列类型
+20 -3
View File
@@ -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,
+2 -2
View File
@@ -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) {
+31
View File
@@ -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 <ScreenshotChatPage />;
}
// 默认渲染主应用
return (
<>
<App />
<Toaster />
</>
);
}
+1
View File
@@ -22,6 +22,7 @@ React 组件层,包含 UI 组件和业务组件。
- `provider-pool/` - Provider 凭证池管理组件
- `resilience/` - 弹性策略配置组件
- `routing/` - 路由规则配置组件
- `screenshot-chat/` - 截图对话功能组件(实验室功能)
- `settings/` - 设置页面组件
- `skills/` - 技能管理组件
- `switch/` - 开关控制组件
@@ -213,46 +213,35 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
// 从 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,否则选择第一个已配置的
@@ -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) {
+1 -2
View File
@@ -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,
};
/**
@@ -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<ChatInputProps> = ({
value,
onChange,
onSend,
disabled = false,
isLoading = false,
placeholder = "输入问题...",
}) => {
const inputRef = useRef<HTMLInputElement>(null);
// 自动聚焦输入框
useEffect(() => {
if (!disabled && inputRef.current) {
inputRef.current.focus();
}
}, [disabled]);
// 处理键盘事件
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey && !disabled && !isLoading) {
e.preventDefault();
if (value.trim()) {
onSend();
}
}
},
[value, onSend, disabled, isLoading],
);
// 处理输入变化
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="screenshot-chat-input-area">
<input
ref={inputRef}
type="text"
className="screenshot-chat-input"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isLoading}
autoFocus
/>
<button
className="screenshot-chat-send-btn"
onClick={handleSendClick}
disabled={!canSend}
title="发送 (Enter)"
>
{isLoading ? (
<span
className="screenshot-chat-loading-spinner"
style={{ width: 16, height: 16 }}
/>
) : (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
)}
</button>
</div>
);
};
export default ChatInput;
@@ -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 (
<div
className={`screenshot-chat-message ${isUser ? "screenshot-chat-message-user" : "screenshot-chat-message-assistant"}`}
>
{/* 用户消息显示图片 */}
{isUser && message.image && (
<div className="screenshot-chat-message-image">
<img
src={`data:${message.image.mediaType};base64,${message.image.data}`}
alt="截图"
className="screenshot-chat-message-thumbnail"
/>
</div>
)}
{/* 消息内容 */}
<div className="screenshot-chat-message-content">
{message.isThinking ? (
<div className="screenshot-chat-thinking">
<span className="screenshot-chat-loading-spinner" />
<span>{message.thinkingContent || "思考中..."}</span>
</div>
) : isUser ? (
<p>{message.content}</p>
) : (
<div className="screenshot-chat-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
{/* 时间戳 */}
<div className="screenshot-chat-message-time">
{new Date(message.timestamp).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
);
};
/**
* 消息列表组件
*
* 显示用户消息和 AI 回复,支持 Markdown 渲染和自动滚动
*
* 需求:
* - 4.5: 悬浮窗口应在可滚动区域显示 AI 回复
* - 5.4: 当 AI 回复时,悬浮窗口应以 Markdown 格式渲染回复内容
*/
export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages,
className = "",
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
if (messages.length === 0) {
return (
<div className={`screenshot-chat-messages ${className}`}>
<div className="screenshot-chat-placeholder">
输入问题,开始与 AI 讨论截图内容
</div>
</div>
);
}
return (
<div ref={containerRef} className={`screenshot-chat-messages ${className}`}>
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</div>
);
};
export default ChatMessages;
+150
View File
@@ -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: 支持快捷键录制模式
@@ -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<ScreenshotChatWindowProps> = ({
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 (
<div className="screenshot-chat-page">
{/* 窗口头部 - 可拖动区域 */}
<div className="screenshot-chat-header">
<span className="screenshot-chat-title">截图对话</span>
<button
className="screenshot-chat-close-btn"
onClick={handleClose}
title="关闭 (ESC)"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* 截图预览区域 */}
{imageSrc && (
<div className="screenshot-chat-preview">
<ScreenshotPreview src={imageSrc} maxHeight={200} />
</div>
)}
{/* 对话区域 */}
<div className="screenshot-chat-conversation">
{/* 错误提示 */}
{error && (
<div className="screenshot-chat-error">
<div className="screenshot-chat-error-content">
<p style={{ color: "#f43f5e", marginBottom: 8 }}>{error}</p>
<button className="screenshot-chat-retry-btn" onClick={retry}>
<svg
className="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
重试
</button>
<button
className="screenshot-chat-retry-btn"
onClick={clearError}
style={{ marginLeft: 8 }}
>
关闭
</button>
</div>
</div>
)}
{/* 消息列表 */}
<ChatMessages messages={messages} />
{/* 输入区域 */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
isLoading={isLoading}
disabled={!imageBase64}
placeholder={imageBase64 ? "输入问题..." : "正在加载图片..."}
/>
</div>
{/* 调试信息(开发模式) */}
{import.meta.env.DEV && (
<div className="screenshot-chat-debug">
路径: {imagePath} | Base64: {imageBase64 ? "已加载" : "未加载"} |
消息数: {messages.length}
</div>
)}
</div>
);
};
export default ScreenshotChatWindow;
@@ -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<ScreenshotPreviewProps> = ({
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 (
<div className={`screenshot-preview ${className}`}>
{/* 工具栏 */}
<div className="screenshot-preview-toolbar">
<button
onClick={handleZoomOut}
title="缩小"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<span className="screenshot-preview-scale">
{Math.round(scale * 100)}%
</span>
<button
onClick={handleZoomIn}
title="放大"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<button
onClick={handleReset}
title="重置"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
</button>
</div>
{/* 图片容器 */}
<div
className="screenshot-preview-container"
style={{ maxHeight: `${maxHeight}px` }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt={alt}
className="screenshot-preview-image"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: isDragging ? "none" : "transform 0.1s ease",
cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default",
}}
draggable={false}
/>
</div>
</div>
);
};
export default ScreenshotPreview;
@@ -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<void>;
/** 验证快捷键回调 */
onValidate?: (shortcut: string) => Promise<boolean>;
/** 是否禁用 */
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<string, string> = {
" ": "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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Refs
const inputRef = useRef<HTMLDivElement>(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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">快捷键</span>
</div>
<div className="flex items-center gap-2">
{/* 快捷键显示/录制区域 */}
<div
ref={inputRef}
tabIndex={isRecording ? 0 : -1}
className={cn(
"flex-1 px-3 py-2 rounded border text-sm font-mono transition-colors",
isRecording
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "bg-muted/50",
disabled && "opacity-50 cursor-not-allowed",
)}
>
{isRecording ? (
<span className="text-muted-foreground">
{recordedShortcut
? formatShortcutDisplay(recordedShortcut)
: "按下快捷键组合..."}
</span>
) : (
<span>{formatShortcutDisplay(displayShortcut)}</span>
)}
</div>
{/* 操作按钮 */}
{isRecording ? (
<>
<button
onClick={saveShortcut}
disabled={!recordedShortcut || isSaving}
className={cn(
"p-2 rounded transition-colors",
recordedShortcut && !isSaving
? "bg-green-500/10 text-green-600 hover:bg-green-500/20"
: "text-muted-foreground opacity-50 cursor-not-allowed",
)}
title="保存"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={cancelRecording}
disabled={isSaving}
className="p-2 rounded text-muted-foreground hover:bg-muted transition-colors"
title="取消"
>
<X className="h-4 w-4" />
</button>
</>
) : (
<button
onClick={startRecording}
disabled={disabled}
className={cn(
"px-3 py-2 rounded border text-sm transition-colors",
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-muted",
)}
>
修改
</button>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
<span>{error}</span>
</div>
)}
{/* 录制提示 */}
{isRecording && !error && (
<p className="text-xs text-muted-foreground">
按下想要设置的快捷键组合,按 ESC 取消
</p>
)}
</div>
);
}
export default ShortcutSettings;
+33
View File
@@ -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";
@@ -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);
}
+152
View File
@@ -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<void>;
/** 设置截图路径 */
setImagePath: (path: string) => void;
/** 清空消息历史 */
clearMessages: () => void;
/** 清除错误 */
clearError: () => void;
/** 重试上一条消息 */
retry: () => Promise<void>;
}
// ============================================================
// 组件 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;
}
@@ -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<string> {
try {
const base64 = await invoke<string>("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<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imagePath, setImagePathState] = useState<string | null>(null);
const [imageBase64, setImageBase64] = useState<string | null>(null);
// 用于重试的最后一条消息
const lastMessageRef = useRef<string | null>(null);
// 会话 ID
const sessionIdRef = useRef<string | null>(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<string | null> => {
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<StreamEvent>(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;
@@ -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<ExperimentalFeatures | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
// 错误状态
if (error && !config) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm font-medium">加载配置失败</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button
onClick={loadConfig}
className="mt-2 text-sm text-primary hover:underline"
>
重试
</button>
</div>
);
}
return (
<div className="space-y-4 max-w-2xl">
{/* 页面标题 */}
<div className="flex items-center gap-2 mb-4">
<FlaskConical className="h-5 w-5 text-primary" />
<div>
<h3 className="text-sm font-medium">实验室功能</h3>
<p className="text-xs text-muted-foreground">
这些功能仍在开发中,可能不稳定
</p>
</div>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"rounded-lg px-3 py-2 text-sm",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-destructive/10 text-destructive",
)}
>
{message.text}
</div>
)}
{/* 截图对话功能 */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Camera className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<h4 className="text-sm font-medium">截图对话</h4>
<p className="text-xs text-muted-foreground mt-0.5">
使用全局快捷键截取屏幕区域,并与 AI 进行对话
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config?.screenshot_chat.enabled ?? false}
onChange={handleToggleScreenshotChat}
disabled={saving}
className="sr-only peer"
/>
<div
className={cn(
"w-9 h-5 rounded-full transition-colors",
"bg-muted peer-checked:bg-primary",
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
"after:bg-white after:rounded-full after:h-4 after:w-4",
"after:transition-transform peer-checked:after:translate-x-4",
saving && "opacity-50 cursor-not-allowed",
)}
/>
</label>
</div>
{/* 快捷键设置 - 仅在功能启用时显示 */}
{config?.screenshot_chat.enabled && (
<div className="pt-3 border-t">
<ShortcutSettings
currentShortcut={config.screenshot_chat.shortcut}
onShortcutChange={handleShortcutChange}
onValidate={handleValidateShortcut}
disabled={saving}
/>
</div>
)}
{/* macOS 权限警告 */}
{isMacOS && config?.screenshot_chat.enabled && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-xs flex-1">
<p className="font-medium text-amber-800 dark:text-amber-300">
需要屏幕录制权限
</p>
<p className="text-amber-700 dark:text-amber-400 mt-0.5">
截图功能需要屏幕录制权限才能正常工作。如果截图只显示桌面背景而不是窗口内容,请授权此权限。
</p>
<button
onClick={async () => {
try {
const { open } = await import("@tauri-apps/plugin-shell");
await open(
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
);
} catch (e) {
console.error("打开系统设置失败:", e);
}
}}
className="mt-2 inline-flex items-center gap-1 px-2 py-1 rounded bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 hover:bg-amber-300 dark:hover:bg-amber-700 transition-colors"
>
打开系统设置
</button>
</div>
</div>
)}
</div>
{/* 更多实验功能占位 */}
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">更多实验功能即将推出...</p>
</div>
</div>
);
}
export default ExperimentalSettings;
+4
View File
@@ -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() {
</div>
)}
{activeTab === "extensions" && <ExtensionsSettings />}
{activeTab === "experimental" && <ExperimentalSettings />}
{activeTab === "developer" && <DeveloperSettings />}
{activeTab === "about" && <AboutSection />}
</div>
+1
View File
@@ -7,3 +7,4 @@ export { TlsSettings } from "./TlsSettings";
export { QuotaSettings } from "./QuotaSettings";
export { RemoteManagementSettings } from "./RemoteManagementSettings";
export { DeveloperSettings } from "./DeveloperSettings";
export { ExperimentalSettings } from "./ExperimentalSettings";
+56 -4
View File
@@ -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<string>("main-terminal");
// 终端输出引用(用于 AI 上下文)
const terminalOutputRef = useRef<string | null>(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 <TerminalPanel panelId={panel.id} cwd={panel.cwd} />;
return (
<TerminalPanel
panelId={panel.id}
cwd={panel.cwd}
onSessionCreated={(sessionId) =>
updatePanelSessionId(panel.id, sessionId)
}
/>
);
case "files":
return <FileBrowserView onOpenTerminal={handleOpenTerminalFromFiles} />;
case "web":
@@ -329,7 +362,12 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
case "sysinfo":
return <SysinfoView />;
case "ai":
return <TerminalAIPanel getTerminalOutput={getTerminalOutput} />;
return (
<TerminalAIPanel
getTerminalOutput={getTerminalOutput}
terminalSessionId={getActiveTerminalSessionId()}
/>
);
default:
return null;
}
@@ -383,14 +421,28 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
<div
style={{ width: 320, minWidth: 280, maxWidth: 400, flexShrink: 0 }}
>
<TerminalAIPanel getTerminalOutput={getTerminalOutput} />
<TerminalAIPanel
getTerminalOutput={getTerminalOutput}
terminalSessionId={getActiveTerminalSessionId()}
/>
</div>
)}
<WorkspaceContainer>
{/* 所有面板统一渲染,都可以关闭和多开 */}
{panels.map((panel) => (
<PanelBlock key={panel.id}>
<PanelBlock
key={panel.id}
$focused={
panel.type === "terminal" && panel.id === activeTerminalPanelId
}
onClick={() => {
// 点击终端面板时设置为活动终端
if (panel.type === "terminal") {
setActiveTerminalPanelId(panel.id);
}
}}
>
<PanelHeader>
{panel.type === "terminal" ? (
<ConnectionSelector
@@ -0,0 +1,186 @@
/**
* @file CommandApproval.tsx
* @description 命令审批组件 - AI 执行命令前的用户确认
* @module components/terminal/ai/CommandApproval
*
* 当 AI 需要在终端执行命令时,显示审批对话框让用户确认。
* 参考 Waveterm 的工具调用审批流程。
*/
import React from "react";
import styled from "styled-components";
import type { PendingCommand } from "./TerminalController";
// ============================================================================
// 样式组件
// ============================================================================
const ApprovalContainer = styled.div`
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 12px;
margin: 8px 0;
`;
const ApprovalHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #f59e0b;
font-size: 13px;
font-weight: 500;
`;
const WarningIcon = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
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 (
<ApprovalContainer>
<ApprovalHeader>
<WarningIcon />
AI 请求执行命令
</ApprovalHeader>
<CommandBox>{command.command}</CommandBox>
<HelpText>此命令将在当前终端中执行。请确认是否允许。</HelpText>
<ButtonGroup>
<Button onClick={() => onReject(command.id)}>拒绝</Button>
<Button $variant="approve" onClick={() => onApprove(command.id)}>
执行
</Button>
</ButtonGroup>
</ApprovalContainer>
);
}
// ============================================================================
// 命令列表组件
// ============================================================================
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) => (
<CommandApproval
key={cmd.id}
command={cmd}
onApprove={onApprove}
onReject={onReject}
/>
))}
</>
);
}
export default CommandApproval;
+21 -2
View File
@@ -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 (
<TerminalAIPanel getTerminalOutput={getTerminalOutput} />
<TerminalAIPanel
getTerminalOutput={getTerminalOutput}
terminalSessionId={terminalSessionId}
/>
);
}
```
@@ -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` - 模型注册表
@@ -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 (
<div className="flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700 text-sm">
<div className="flex items-center gap-2">
<span className={cn("font-bold", statusColor)}>{statusIcon}</span>
<span className="font-medium">{toolCall.name}</span>
</div>
{toolCall.result?.error && (
<div className="text-red-300 pl-5">{toolCall.result.error}</div>
// 状态图标
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 (
<div
className={cn(
"flex flex-col gap-1.5 p-2.5 rounded-md border text-sm",
bgColor,
)}
>
{/* 工具名称和状态 */}
<div className="flex items-center gap-2">
<span className={cn("font-bold text-base", iconColor)}>
{statusIcon}
</span>
<span className={cn("font-medium", nameColor)}>{toolCall.name}</span>
{isRunning && (
<span className="text-xs text-zinc-500 animate-pulse">执行中...</span>
)}
</div>
);
},
);
{/* 显示结果 */}
{toolCall.result && (
<div className="pl-6 space-y-1">
{/* 成功输出 */}
{toolCall.result.success && toolCall.result.output && (
<div className="text-zinc-300 text-xs font-mono whitespace-pre-wrap break-all max-h-32 overflow-y-auto bg-zinc-900/50 rounded p-2">
{toolCall.result.output.length > 500
? toolCall.result.output.slice(0, 500) + "..."
: toolCall.result.output}
</div>
)}
{/* 错误信息 */}
{toolCall.result.error && (
<div className="text-red-300 text-xs">
<span className="font-medium">错误: </span>
{toolCall.result.error}
</div>
)}
{/* 退出码(如果有) */}
{!toolCall.result.success && !toolCall.result.error && (
<div className="text-red-300 text-xs">命令执行失败</div>
)}
</div>
)}
</div>
);
});
ToolCallDisplay.displayName = "ToolCallDisplay";
@@ -145,11 +145,13 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
}
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<TerminalAIModeSelectorProps> = ({
}
// 从 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]);
// 自动选择第一个模型
+64 -6
View File
@@ -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<TerminalAIPanelProps> = ({
getTerminalOutput,
terminalSessionId,
className,
}) => {
const [input, setInput] = useState("");
@@ -54,8 +59,25 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
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<TerminalAIPanelProps> = ({
}, []);
const hasMessages = messages.length > 0;
const hasPendingCommands =
pendingCommands.filter((c) => c.status === "pending").length > 0;
return (
<div
@@ -87,6 +111,18 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-yellow-400" />
<span className="font-medium text-zinc-200">Terminal AI</span>
{/* 终端连接状态指示器 */}
{isTerminalConnected ? (
<span className="flex items-center gap-1 text-xs text-green-400">
<Terminal size={12} />
已连接
</span>
) : (
<span className="flex items-center gap-1 text-xs text-zinc-500">
<Unplug size={12} />
未连接
</span>
)}
</div>
{/* 更多菜单 */}
@@ -121,6 +157,19 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
/>
</div>
{/* 自动执行开关 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-700/50">
<div className="flex flex-col">
<span className="text-sm text-zinc-400">自动执行命令</span>
<span className="text-xs text-zinc-500">AI 命令无需手动批准</span>
</div>
<Switch
checked={config.autoExecute}
onCheckedChange={toggleAutoExecute}
className="data-[state=checked]:bg-green-500"
/>
</div>
{/* 模式选择器 */}
<div className="px-3 py-2 border-b border-zinc-700/50">
<TerminalAIModeSelector
@@ -131,6 +180,17 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
/>
</div>
{/* 待审批命令 */}
{hasPendingCommands && (
<div className="px-3 py-2 border-b border-zinc-700/50">
<CommandApprovalList
commands={pendingCommands.filter((c) => c.status === "pending")}
onApprove={approveCommand}
onReject={rejectCommand}
/>
</div>
)}
{/* 消息区域 */}
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{hasMessages ? (
@@ -146,9 +206,7 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
onChange={setInput}
onSubmit={handleSend}
isSending={isSending}
placeholder={
hasMessages ? "Continue..." : "Ask Terminal AI anything..."
}
placeholder={hasMessages ? "继续对话..." : "向 Terminal AI 提问..."}
/>
</div>
);
@@ -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<string, PendingCommand> = 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<string> {
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<void> {
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<void> {
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<void> {
if (!this.sessionId) {
throw new Error("终端未连接");
}
await writeToTerminal(this.sessionId, data);
}
/**
* 发送 Ctrl+C 中断信号
*/
async sendInterrupt(): Promise<void> {
await this.sendData("\x03");
}
/**
* 发送 Ctrl+D EOF 信号
*/
async sendEOF(): Promise<void> {
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;
}
}
+10
View File
@@ -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";
+35
View File
@@ -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<void>;
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<string>;
approveCommand: (commandId: string) => Promise<void>;
rejectCommand: (commandId: string) => Promise<void>;
}
+401 -3
View File
@@ -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<string | null>(null);
// 终端控制状态
const [terminalSessionId, setTerminalSessionId] = useState<string | null>(
null,
);
const [pendingCommands, setPendingCommands] = useState<
PendingTerminalCommand[]
>([]);
const pendingCommandsRef = useRef<Map<string, PendingTerminalCommand>>(
new Map(),
);
// 使用 ref 存储最新的 config,避免闭包问题
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
// 使用 ref 存储 approveCommand 函数引用
const approveCommandRef = useRef<
((commandId: string) => Promise<void>) | 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<TerminalCommandRequest>(
"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<TermScrollbackRequest>(
"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<string> => {
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<void> => {
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<void> => {
await approveCommandInternal(commandId);
},
[approveCommandInternal],
);
/**
* 拒绝命令
*/
const rejectCommand = useCallback(
async (commandId: string): Promise<void> => {
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,
};
}
-8
View File
@@ -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: "机器码管理工具",
+36 -16
View File
@@ -164,35 +164,55 @@ export function useModelRegistry(
const [error, setError] = useState<string | null>(null);
const [lastSyncAt, setLastSyncAt] = useState<number | null>(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));
+63
View File
@@ -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<NetworkInfo> {
return safeInvoke("get_network_info");
}
// ============ 实验室功能 API ============
/**
* 获取实验室功能配置
* @returns 实验室功能配置对象
*/
export async function getExperimentalConfig(): Promise<ExperimentalFeatures> {
return safeInvoke("get_experimental_config");
}
/**
* 保存实验室功能配置
* @param config 实验室功能配置对象
*/
export async function saveExperimentalConfig(
config: ExperimentalFeatures,
): Promise<void> {
return safeInvoke("save_experimental_config", {
experimentalConfig: config,
});
}
/**
* 验证快捷键格式
* @param shortcut 快捷键字符串
* @returns 是否有效
*/
export async function validateShortcut(shortcut: string): Promise<boolean> {
return safeInvoke("validate_shortcut", { shortcutStr: shortcut });
}
/**
* 更新截图快捷键
* @param shortcut 新的快捷键字符串
*/
export async function updateScreenshotShortcut(
shortcut: string,
): Promise<void> {
return safeInvoke("update_screenshot_shortcut", { newShortcut: shortcut });
}
+116
View File
@@ -313,6 +313,7 @@ export async function sendAgentMessageStream(
model?: string,
images?: ImageInput[],
provider?: string,
terminalMode?: boolean,
): Promise<void> {
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<GooseProviderInfo[]> {
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<void> {
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<void> {
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,
});
}
+3 -2
View File
@@ -21,9 +21,10 @@ export async function getModelRegistry(): Promise<EnhancedModelMetadata[]> {
}
/**
* 刷新模型注册表(从 models.dev 获取最新数据)
* 刷新模型注册表(强制从内嵌资源重新加载)
* @returns 加载的模型数量
*/
export async function refreshModelRegistry(): Promise<void> {
export async function refreshModelRegistry(): Promise<number> {
return invoke("refresh_model_registry");
}
+1 -1
View File
@@ -34,6 +34,7 @@ const loadedPlugins = new Map<string, PluginModule>();
/**
* 插件 ID 到全局变量名的映射
* 格式: pluginId -> GlobalVariableName
* 注意: terminal-plugin 已移除,终端功能已内置到应用中
*/
const PLUGIN_GLOBAL_NAMES: Record<string, string> = {
"kiro-provider": "KiroProviderPlugin",
@@ -42,7 +43,6 @@ const PLUGIN_GLOBAL_NAMES: Record<string, string> = {
"gemini-provider": "GeminiProviderPlugin",
"antigravity-provider": "AntigravityProviderPlugin",
"codex-provider": "CodexProviderPlugin",
"terminal-plugin": "TerminalPlugin",
};
/**
+2 -9
View File
@@ -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(
<>
<App />
<Toaster />
</>,
);
ReactDOM.createRoot(document.getElementById("root")!).render(<RootRouter />);
+1
View File
@@ -10,6 +10,7 @@
## 文件索引
- `FlowMonitorPage.tsx` - LLM 流量监控页面
- `screenshot-chat.tsx` - 截图对话悬浮窗口页面(独立 Tauri 窗口)
- `index.ts` - 页面导出入口
## 更新提醒
+1
View File
@@ -1 +1,2 @@
export { FlowMonitorPage } from "./FlowMonitorPage";
export { ScreenshotChatPage } from "./screenshot-chat";
+293
View File
@@ -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;
}
}
+197
View File
@@ -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 (
<svg
viewBox="0 0 128 128"
width="20"
height="20"
className="screenshot-logo"
>
<defs>
<linearGradient id="leftP" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: "#4fc3f7" }} />
<stop offset="100%" style={{ stopColor: "#1a237e" }} />
</linearGradient>
<linearGradient id="rightP" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: "#7c4dff" }} />
<stop offset="100%" style={{ stopColor: "#e91e63" }} />
</linearGradient>
</defs>
<g>
<rect x="36" y="32" width="10" height="64" rx="3" fill="url(#leftP)" />
<rect x="46" y="32" width="28" height="9" rx="3" fill="url(#rightP)" />
<rect x="46" y="60" width="24" height="8" rx="2" fill="url(#rightP)" />
<rect x="70" y="41" width="8" height="27" rx="3" fill="url(#rightP)" />
</g>
</svg>
);
}
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<string | null>(null);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="screenshot-container">
<div className="screenshot-input-bar">
{/* 拖动手柄 */}
<div
className="screenshot-drag-handle"
onMouseDown={handleStartDrag}
title="拖动移动窗口"
>
<GripVertical size={14} />
</div>
{/* Logo */}
<Logo />
{/* 图片附件标签 */}
{imagePath && (
<div className="screenshot-attachment">
<ImageIcon size={12} />
<span>Image</span>
<button
className="screenshot-attachment-remove"
onClick={handleRemoveImage}
title="移除图片"
>
<X size={10} />
</button>
</div>
)}
{/* 输入框 */}
<input
ref={inputRef}
type="text"
className="screenshot-input"
placeholder="Ask anything..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
disabled={isLoading}
/>
{/* 右侧按钮组 */}
<div className="screenshot-actions">
{/* 关闭按钮 */}
<button
className="screenshot-close-btn"
onClick={handleClose}
title="关闭 (ESC)"
>
<X size={14} />
</button>
{/* 发送按钮 */}
<button
className={`screenshot-send-btn ${inputValue.trim() ? "active" : ""}`}
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
title="发送 (Enter)"
>
<ArrowUp size={16} />
</button>
</div>
</div>
</div>
);
}
export default ScreenshotChatPage;
+15
View File
@@ -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 "✅ 资源文件同步完成!"