feat: add terminal broadcast, context menu, and shell close
Complete multi-pane broadcast, standard terminal right-click actions, batch host selection with progress, and native ssh_close_shell so closing a pane tears down the remote shell. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
494ef6b9d0
commit
9ce71795b8
38 files changed
+1437
-186
No files matched your search
@@ -29,7 +29,7 @@ React 不直接访问 SSH、文件系统或加密实现。新增原生能力时
|
|||||||
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
|
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
|
||||||
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
|
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
|
||||||
- 大模型新配置必须先用临时凭据完成真实测试,测试期间不得写 settings 或 secrets;连接字段变化后保存必须重新锁定。
|
- 大模型新配置必须先用临时凭据完成真实测试,测试期间不得写 settings 或 secrets;连接字段变化后保存必须重新锁定。
|
||||||
- AI 远程执行只能复用当前活跃 SSH connection,每次执行前显式确认;不建立独立 podexec/Kubernetes 凭据边界,也不保存命令或工具输出。
|
- AI 远程执行只能复用当前活跃 SSH connection;按 strict/balanced/permissive 模式决定确认阈值,critical 始终硬拒绝;不建立独立 podexec/Kubernetes 凭据边界,也不保存命令或工具输出。
|
||||||
- `ssh://` 禁止携带密码或静默直连;冷启动与运行中唤起都必须通过原生有界队列交付给快速连接确认。
|
- `ssh://` 禁止携带密码或静默直连;冷启动与运行中唤起都必须通过原生有界队列交付给快速连接确认。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
|
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段;界面支持单跳选择,Rust 后端支持最多 8 跳
|
||||||
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
|
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
|
||||||
- 本地终端与串口:系统 Shell、串口设备与波特率配置
|
- 本地终端与串口:系统 Shell、串口设备与波特率配置
|
||||||
- SSH Agent CLI:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;自然语言驱动最多 8 步的执行→观察循环,每步确认、五级风险和破坏性命令硬拒绝
|
- SSH Agent CLI:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;自然语言驱动最多 8 步的执行→观察循环,三级安全模式、五级风险和破坏性命令硬拒绝
|
||||||
- 本地安全:AES-256-GCM 加密会话密码、密钥口令和模型 API Key,不保存终端命令或 AI 对话历史
|
- 本地安全:AES-256-GCM 加密会话密码、密钥口令和模型 API Key,不保存终端命令或 AI 对话历史
|
||||||
|
|
||||||
## 产品边界
|
## 产品边界
|
||||||
@@ -58,10 +58,11 @@ npm run dev
|
|||||||
npm run acceptance:quick
|
npm run acceptance:quick
|
||||||
npm run acceptance:full
|
npm run acceptance:full
|
||||||
./build.sh v2.1.1 mac-arm64
|
./build.sh v2.1.1 mac-arm64
|
||||||
|
build.bat v2.1.1 win-x64
|
||||||
./release.py v2.1.1 --dry-run
|
./release.py v2.1.1 --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.sh` 会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
`build.sh`(macOS/Git Bash)和 `build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ scripts/ 构建和验收脚本
|
|||||||
- [使用指南](docs/user-guide.md)
|
- [使用指南](docs/user-guide.md)
|
||||||
- [架构说明](docs/architecture.md)
|
- [架构说明](docs/architecture.md)
|
||||||
- [SSH Agent CLI 设计](docs/ai-agent.md)
|
- [SSH Agent CLI 设计](docs/ai-agent.md)
|
||||||
|
- [产品体验与基础能力评估](docs/ux-foundation-audit.md)
|
||||||
- [测试与验收](docs/testing-acceptance.md)
|
- [测试与验收](docs/testing-acceptance.md)
|
||||||
- [发布指南](docs/release.md)
|
- [发布指南](docs/release.md)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
- `ssh.rs` 已拆出认证、known_hosts、代理/ProxyJump、路径和 SFTP 模块
|
- `ssh.rs` 已拆出认证、known_hosts、代理/ProxyJump、路径和 SFTP 模块
|
||||||
- `tunnel.rs` 已拆出运行模型、SOCKS5 协议与 Relay 模块
|
- `tunnel.rs` 已拆出运行模型、SOCKS5 协议与 Relay 模块
|
||||||
- 注册 `ssh://` 系统链接,支持冷启动队列和运行中单实例唤起
|
- 注册 `ssh://` 系统链接,支持冷启动队列和运行中单实例唤起
|
||||||
- SSH Agent CLI 单任务入口、8 步执行观察循环、五级风险、critical 硬拒绝、输出分析和重复命令拦截
|
- SSH Agent CLI 单任务入口、8 步执行观察循环、三级安全模式、五级风险、critical 硬拒绝、输出分析和重复命令拦截
|
||||||
|
- 全终端广播、批量主机选择与执行进度、终端标准右键菜单和远端单 Shell 关闭
|
||||||
- 远程 Shell EOF/TMOUT 与 SSH 连接生命周期分离,支持单分屏一键重开
|
- 远程 Shell EOF/TMOUT 与 SSH 连接生命周期分离,支持单分屏一键重开
|
||||||
|
|
||||||
## 当前重点
|
## 当前重点
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
|
chcp 65001 >nul
|
||||||
|
|
||||||
|
rem OpsXShell v2.1.x native Windows release builder.
|
||||||
|
rem Run this script from Windows x64 with the MSVC Rust toolchain installed.
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set "VERSION_ARG="
|
||||||
|
set "CHECK_MODE=full"
|
||||||
|
set "RUST_TARGET=x86_64-pc-windows-msvc"
|
||||||
|
set "OUTPUT_PLATFORM=windows-x64"
|
||||||
|
|
||||||
|
:parse_args
|
||||||
|
if "%~1"=="" goto args_done
|
||||||
|
set "ARG=%~1"
|
||||||
|
|
||||||
|
if /I "!ARG!"=="-h" goto usage_ok
|
||||||
|
if /I "!ARG!"=="--help" goto usage_ok
|
||||||
|
if /I "!ARG!"=="--quick" (
|
||||||
|
set "CHECK_MODE=quick"
|
||||||
|
shift
|
||||||
|
goto parse_args
|
||||||
|
)
|
||||||
|
if /I "!ARG!"=="--skip-checks" (
|
||||||
|
set "CHECK_MODE=none"
|
||||||
|
shift
|
||||||
|
goto parse_args
|
||||||
|
)
|
||||||
|
if /I "!ARG!"=="win-x64" (
|
||||||
|
shift
|
||||||
|
goto parse_args
|
||||||
|
)
|
||||||
|
if /I "!ARG:~0,4!"=="v2.1" goto set_version_arg
|
||||||
|
if /I "!ARG:~0,4!"=="2.1." goto set_version_arg
|
||||||
|
call :fail "未知参数:!ARG!(使用 build.bat --help 查看用法)"
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:set_version_arg
|
||||||
|
if defined VERSION_ARG (
|
||||||
|
call :fail "只能指定一个版本号"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set "VERSION_ARG=!ARG!"
|
||||||
|
if /I "!VERSION_ARG:~0,1!"=="v" set "VERSION_ARG=!VERSION_ARG:~1!"
|
||||||
|
shift
|
||||||
|
goto parse_args
|
||||||
|
|
||||||
|
:args_done
|
||||||
|
if /I not "%OS%"=="Windows_NT" (
|
||||||
|
call :fail "build.bat 必须在 Windows 环境运行"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for %%C in (node npm npx cargo rustc powershell) do (
|
||||||
|
where %%C >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :fail "缺少构建依赖:%%C"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for /f "usebackq delims=" %%V in (`node -p "require('./package.json').version"`) do set "CURRENT_VERSION=%%V"
|
||||||
|
if not defined CURRENT_VERSION (
|
||||||
|
call :fail "无法读取 package.json 版本"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined VERSION_ARG (
|
||||||
|
set "VERSION=%VERSION_ARG%"
|
||||||
|
) else (
|
||||||
|
set "VERSION=%CURRENT_VERSION%"
|
||||||
|
)
|
||||||
|
|
||||||
|
node -e "if (!/^2\.1\.\d+(?:[.-][0-9A-Za-z.-]+)?$/.test(process.argv[1])) process.exit(1)" "%VERSION%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :fail "版本必须是 v2.1.x 语义版本,当前输入:%VERSION%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
where rustup >nul 2>nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
rustup target list --installed | findstr /X /C:"%RUST_TARGET%" >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :fail "Rust target 未安装:%RUST_TARGET%;请先运行 rustup target add %RUST_TARGET%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if defined VERSION_ARG if /I not "%VERSION%"=="%CURRENT_VERSION%" (
|
||||||
|
call :log "同步版本号到 v%VERSION%"
|
||||||
|
node scripts\set-version.mjs "%VERSION%"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
node scripts\set-version.mjs --check "%VERSION%"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
if not exist node_modules\ (
|
||||||
|
call :log "安装锁定依赖"
|
||||||
|
call npm ci
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "GIT_SHA=unknown"
|
||||||
|
where git >nul 2>nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
for /f "delims=" %%G in ('git rev-parse --short HEAD 2^>nul') do set "GIT_SHA=%%G"
|
||||||
|
)
|
||||||
|
call :log "OpsXShell v%VERSION% · win-x64 · !GIT_SHA!"
|
||||||
|
|
||||||
|
if /I "%CHECK_MODE%"=="full" (
|
||||||
|
call :log "运行完整发布门禁"
|
||||||
|
call npm run acceptance:full
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
) else if /I "%CHECK_MODE%"=="quick" (
|
||||||
|
call :log "运行快速发布门禁"
|
||||||
|
call npm run acceptance:quick
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
) else (
|
||||||
|
call :log "已按参数跳过构建前门禁"
|
||||||
|
)
|
||||||
|
|
||||||
|
node scripts\release-preflight.mjs windows
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
call :log "构建 Windows x86_64 原生安装包"
|
||||||
|
call npx tauri build --ci --target "%RUST_TARGET%"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
set "BUNDLE_ROOT=src-tauri\target\%RUST_TARGET%\release\bundle"
|
||||||
|
node scripts\verify-release.mjs windows "%BUNDLE_ROOT%"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
set "OUTPUT_DIR=%CD%\release\v%VERSION%\%OUTPUT_PLATFORM%"
|
||||||
|
if exist "%OUTPUT_DIR%" rmdir /S /Q "%OUTPUT_DIR%"
|
||||||
|
mkdir "%OUTPUT_DIR%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :fail "无法创建输出目录:%OUTPUT_DIR%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set /A ARTIFACT_COUNT=0
|
||||||
|
for %%F in ("%BUNDLE_ROOT%\nsis\*.exe" "%BUNDLE_ROOT%\msi\*.msi" "%BUNDLE_ROOT%\wix\*.msi") do (
|
||||||
|
if exist "%%~fF" (
|
||||||
|
copy /Y "%%~fF" "%OUTPUT_DIR%\%%~nxF" >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :fail "复制安装包失败:%%~fF"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set /A ARTIFACT_COUNT+=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%ARTIFACT_COUNT%"=="0" (
|
||||||
|
call :fail "构建完成但未找到 NSIS/MSI 安装包:%BUNDLE_ROOT%"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$files = Get-ChildItem -LiteralPath $env:OUTPUT_DIR -File ^| Where-Object { $_.Extension -in '.exe', '.msi' };" ^
|
||||||
|
"$lines = @($files ^| ForEach-Object { (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() + ' ' + $_.Name });" ^
|
||||||
|
"[System.IO.File]::WriteAllLines((Join-Path $env:OUTPUT_DIR 'SHA256SUMS.txt'), [string[]]$lines, (New-Object System.Text.UTF8Encoding($false)))"
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :fail "生成 SHA256SUMS.txt 失败"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
call :log "构建完成:%OUTPUT_DIR%"
|
||||||
|
dir "%OUTPUT_DIR%"
|
||||||
|
echo.
|
||||||
|
echo [build] SHA-256
|
||||||
|
type "%OUTPUT_DIR%\SHA256SUMS.txt"
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:usage_ok
|
||||||
|
call :usage
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:usage
|
||||||
|
echo OpsXShell Windows 原生安装包构建脚本
|
||||||
|
echo.
|
||||||
|
echo 用法:
|
||||||
|
echo build.bat [v2.1.x] [win-x64] [选项]
|
||||||
|
echo.
|
||||||
|
echo 选项:
|
||||||
|
echo --quick 构建前只运行快速门禁
|
||||||
|
echo --skip-checks 跳过门禁,仅用于本地重复打包
|
||||||
|
echo -h, --help 显示帮助
|
||||||
|
echo.
|
||||||
|
echo 示例:
|
||||||
|
echo build.bat
|
||||||
|
echo build.bat v2.1.1
|
||||||
|
echo build.bat v2.1.2 win-x64 --quick
|
||||||
|
echo.
|
||||||
|
echo 环境要求:
|
||||||
|
echo Windows 10/11 x64、Node.js 18+、Rust MSVC toolchain、
|
||||||
|
echo Visual Studio 2022 Build Tools(Desktop development with C++)、
|
||||||
|
echo Windows 10/11 SDK、WebView2 Runtime。
|
||||||
|
echo.
|
||||||
|
echo 说明:
|
||||||
|
echo - 输出 NSIS/MSI 到 release\v版本\windows-x64 并生成 SHA256SUMS.txt。
|
||||||
|
echo - 脚本不会创建或推送 Git tag,也不会上传 Release。
|
||||||
|
echo - 正式签名需设置 REQUIRE_SIGNING=true 和 Windows 签名环境变量。
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:log
|
||||||
|
echo.
|
||||||
|
echo [build] %~1
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:fail
|
||||||
|
echo.
|
||||||
|
echo [build] ERROR: %~1 1>&2
|
||||||
|
exit /b 1
|
||||||
+8
-7
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
|
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| ----------------------------------- | -------------------------------------------- |
|
| -------------------------------------------- | ------------------------------------------------- |
|
||||||
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
||||||
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
||||||
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
||||||
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
| [产品体验全面评估](./ux-foundation-audit.md) | 全产品评分、大众操作习惯、P0/P1/P2 与统一回归清单 |
|
||||||
| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 |
|
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
||||||
|
| [发布指南](./release.md) | macOS/Windows 构建、签名、产物和临时文件清理 |
|
||||||
|
|
||||||
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP](../ROADMAP.md) 维护。
|
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP](../ROADMAP.md) 维护。
|
||||||
+14
-14
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 WebSocket、服务器资产库、审计、审批、Kubernetes 专用工具和默认持久化记忆。
|
OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的 WebSocket、服务器资产库、审计、审批、Kubernetes 专用工具和默认持久化记忆。
|
||||||
|
|
||||||
产品目标是:用户在已连接的 SSH 终端中输入自然语言任务,Agent 通过少量、边界清晰的工具获取真实证据,在每次真实执行前展示命令和风险并等待确认,失败后根据输出换方法,最后给出有证据的结论。
|
产品目标是:用户在已连接的 SSH 终端中输入自然语言任务,Agent 通过少量、边界清晰的工具获取真实证据,按用户选择的安全模式决定确认阈值,失败后根据输出换方法,最后给出有证据的结论。无论选择哪种模式,目标主机始终可见,critical 命令始终硬拒绝。
|
||||||
|
|
||||||
## 参考来源
|
## 参考来源
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
|||||||
-> LLM 选择下一条单行命令,或返回 FINAL
|
-> LLM 选择下一条单行命令,或返回 FINAL
|
||||||
-> 五级命令风险评估
|
-> 五级命令风险评估
|
||||||
-> critical 破坏性操作硬拒绝
|
-> critical 破坏性操作硬拒绝
|
||||||
-> 显示目标、风险和命令,用户显式确认
|
-> 按本次运行锁定的安全模式决定是否确认
|
||||||
-> ssh_execute 使用当前 connectionId
|
-> ssh_execute 使用当前 connectionId
|
||||||
-> 返回 stdout + stderr + exit_code
|
-> 返回 stdout + stderr + exit_code
|
||||||
-> 输出过长时本地 analyze_output
|
-> 输出过长时本地 analyze_output
|
||||||
@@ -43,7 +43,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
|||||||
-> 失败换方法 / 继续验证 / FINAL
|
-> 失败换方法 / 继续验证 / FINAL
|
||||||
```
|
```
|
||||||
|
|
||||||
- 单次任务最多 8 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 每步都显式确认,8 步更符合桌面 GUI 的注意力和成本边界。
|
- 单次任务最多 8 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,8 步更符合注意力、时延和成本边界。
|
||||||
- 相同命令最多出现 2 次,第 3 次在执行前停止。
|
- 相同命令最多出现 2 次,第 3 次在执行前停止。
|
||||||
- 第 4 轮检查是否已有足够证据,第 8 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。
|
- 第 4 轮检查是否已有足够证据,第 8 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。
|
||||||
- 单步输出进入 Agent 前有 12,000 字符上限,任务观察上下文有 48,000 字符上限。
|
- 单步输出进入 Agent 前有 12,000 字符上限,任务观察上下文有 48,000 字符上限。
|
||||||
@@ -51,15 +51,15 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
|||||||
|
|
||||||
### 安全边界
|
### 安全边界
|
||||||
|
|
||||||
| 等级 | 示例 | 当前处理 |
|
| 等级 | 示例 | 严格模式 | 均衡模式(默认) | 宽松模式 |
|
||||||
| -------- | --------------------------------- | ---------------------- |
|
| -------- | --------------------------------- | -------- | ---------------- | -------- |
|
||||||
| safe | `df -h`、`journalctl -u nginx` | 显示后确认 |
|
| safe | `df -h`、`journalctl -u nginx` | 自动执行 | 自动执行 | 自动执行 |
|
||||||
| low | 未知自定义查询命令 | 显示后确认 |
|
| low | 未知自定义查询命令 | 自动执行 | 自动执行 | 自动执行 |
|
||||||
| medium | 文件写入、普通权限变更 | 风险提示后确认 |
|
| medium | 文件写入、普通权限变更 | 先确认 | 自动执行 | 自动执行 |
|
||||||
| high | 服务重启、删除、包安装、K8s 变更 | 红色风险确认 |
|
| high | 服务重启、删除、包安装、K8s 变更 | 先确认 | 先确认 | 自动执行 |
|
||||||
| critical | 格式化磁盘、删除根目录、fork bomb | 硬拒绝,不显示放行按钮 |
|
| critical | 格式化磁盘、删除根目录、fork bomb | 硬拒绝 | 硬拒绝 | 硬拒绝 |
|
||||||
|
|
||||||
此处不直接引入 aiops 的 `strict / balanced / permissive` 三级模式。原因是 OpsXShell 已对用户承诺“AI 每次真实执行前显式确认”,`permissive` 静默执行会破坏这条简单、可理解的规则。未来如需减少确认,应引入边界更明确的“仅允许 Rust 认定的纯只读命令自动执行”,而不是模糊的宽松模式。
|
安全模式在任务开始时锁定,运行中不能切换;需要确认时,对话框必须显示本次锁定模式、目标、风险和完整单行命令。宽松模式明确标注“仅适合隔离测试环境”,它不会降低 critical 硬红线。
|
||||||
|
|
||||||
终端输出被标记为不可信数据,不得将日志中的“忽略规则”等文字当作指令。常见密码、Token、Authorization Header、AWS Key 和私钥块在 Rust 发送 Provider 前脱敏。
|
终端输出被标记为不可信数据,不得将日志中的“忽略规则”等文字当作指令。常见密码、Token、Authorization Header、AWS Key 和私钥块在 Rust 发送 Provider 前脱敏。
|
||||||
|
|
||||||
@@ -74,10 +74,10 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
|||||||
| OpenAI / Anthropic LLM 适配 | 已吸收 | 保留 OpenAI Chat、Responses、Anthropic Compatible 及自定义 Provider |
|
| OpenAI / Anthropic LLM 适配 | 已吸收 | 保留 OpenAI Chat、Responses、Anthropic Compatible 及自定义 Provider |
|
||||||
| `ssh_execute` | 已吸收 | 固定使用当前活跃 `connectionId`,不重新管理一套凭据 |
|
| `ssh_execute` | 已吸收 | 固定使用当前活跃 `connectionId`,不重新管理一套凭据 |
|
||||||
| `analyze_output` | 已适配 | 本地确定性摘要,降低 token 和幻觉 |
|
| `analyze_output` | 已适配 | 本地确定性摘要,降低 token 和幻觉 |
|
||||||
| ReAct 16 轮 | 适配为 8 步 | GUI 每步确认,优先快速收敛 |
|
| ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 |
|
||||||
| 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 |
|
| 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 |
|
||||||
| 五级风险 | 已吸收 | critical 硬拒绝,其他均显式确认 |
|
| 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 |
|
||||||
| 三级安全模式 | 暂不吸收 | 保持单一可理解的“每步确认”规则 |
|
| 三级安全模式 | 已适配 | 严格从 medium、均衡从 high 确认;宽松仍不可绕过 critical |
|
||||||
| WebSocket 事件流 | 不照搬 | 本地 React/Tauri 没有跨程服务器会话,使用内存状态即可 |
|
| WebSocket 事件流 | 不照搬 | 本地 React/Tauri 没有跨程服务器会话,使用内存状态即可 |
|
||||||
| 审计、审批、RBAC | 不引入 | 属于 aiops/堡垒机,不是本地 SSH 客户端职责 |
|
| 审计、审批、RBAC | 不引入 | 属于 aiops/堡垒机,不是本地 SSH 客户端职责 |
|
||||||
| podexec/K8s 专用工具 | 不引入 | `kubectl/helm` 可沿远程 SSH 工具使用,不引入第二套目标和凭据边界 |
|
| podexec/K8s 专用工具 | 不引入 | `kubectl/helm` 可沿远程 SSH 工具使用,不引入第二套目标和凭据边界 |
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ XTerm 当前焦点分屏
|
|||||||
-> AiService (OpenAI Chat / Responses / Anthropic)
|
-> AiService (OpenAI Chat / Responses / Anthropic)
|
||||||
-> 下一条唯一单行命令 / FINAL 结论
|
-> 下一条唯一单行命令 / FINAL 结论
|
||||||
-> 五级风险;critical 硬拒绝
|
-> 五级风险;critical 硬拒绝
|
||||||
-> 展示目标、命令和风险,用户确认
|
-> 按本次运行锁定的安全模式决定是否确认
|
||||||
-> 当前 connectionId 的 ssh_exec
|
-> 当前 connectionId 的 ssh_exec
|
||||||
-> stdout/stderr/exit_code + 本地 analyze_output
|
-> stdout/stderr/exit_code + 本地 analyze_output
|
||||||
-> 真实观察脱敏后交给 AI 判断下一步
|
-> 真实观察脱敏后交给 AI 判断下一步
|
||||||
@@ -83,8 +83,8 @@ XTerm 当前焦点分屏
|
|||||||
- AI 对话、提示词、终端上下文、命令和工具输出只存在当前面板内存中;关闭面板即清空。
|
- AI 对话、提示词、终端上下文、命令和工具输出只存在当前面板内存中;关闭面板即清空。
|
||||||
- 新 Provider 的测试命令接收临时 API Key 并发出最小真实完成请求;测试过程不调用凭据存储。只有当前连接字段快照测试成功,界面才允许保存。
|
- 新 Provider 的测试命令接收临时 API Key 并发出最小真实完成请求;测试过程不调用凭据存储。只有当前连接字段快照测试成功,界面才允许保存。
|
||||||
- 模型返回内容按不可信文本处理;终端输出也标记为不可信,其中的指令不能覆盖 system Prompt。
|
- 模型返回内容按不可信文本处理;终端输出也标记为不可信,其中的指令不能覆盖 system Prompt。
|
||||||
- Agent 只接受唯一单行命令,目标固定为当前活跃 SSH connection;每次调用 `ssh_exec` 前都显示目标、命令和风险确认。
|
- Agent 只接受唯一单行命令,目标固定为当前活跃 SSH connection;本次运行开始后安全模式锁定,确认框必须显示目标、命令、风险和锁定模式。
|
||||||
- 命令风险为 safe/low/medium/high/critical 五级;格式化磁盘、删除根目录等 critical 操作硬拒绝。当前不引入会静默执行的“宽松模式”。
|
- 命令风险为 safe/low/medium/high/critical 五级。严格模式从 medium 起确认;默认均衡模式从 high 起确认;宽松模式仅适合隔离测试环境。格式化磁盘、删除根目录等 critical 操作在任何模式下都硬拒绝。
|
||||||
- 相同命令第 3 次在执行前拦截;连续失败时要求更换排查路径。长输出先经本地 `analyze_output` 保留错误行和首尾。
|
- 相同命令第 3 次在执行前拦截;连续失败时要求更换排查路径。长输出先经本地 `analyze_output` 保留错误行和首尾。
|
||||||
- 不创建独立 Kubernetes 客户端或 podexec。集群运维命令沿当前 SSH 会话执行,避免引入本机 kubeconfig 与第二套目标/凭据边界。
|
- 不创建独立 Kubernetes 客户端或 podexec。集群运维命令沿当前 SSH 会话执行,避免引入本机 kubeconfig 与第二套目标/凭据边界。
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -9,14 +9,14 @@
|
|||||||
# macOS Apple Silicon 指定版本
|
# macOS Apple Silicon 指定版本
|
||||||
./build.sh v2.1.1 mac-arm64
|
./build.sh v2.1.1 mac-arm64
|
||||||
|
|
||||||
# Windows x86_64(在 Windows Git Bash 中执行)
|
# Windows x86_64(推荐在 Windows CMD/PowerShell 中执行)
|
||||||
./build.sh v2.1.1 win-x64
|
build.bat v2.1.1 win-x64
|
||||||
|
|
||||||
# 本地重复构建时缩短门禁;正式发布不要跳过
|
# 本地重复构建时缩短门禁;正式发布不要跳过
|
||||||
./build.sh v2.1.1 mac-arm64 --quick
|
./build.sh v2.1.1 mac-arm64 --quick
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本面向 v2.1.x,会检查 Node、npm、Rust target 和五处版本元数据。显式传入不同于当前源码的新版本时,脚本会同步版本文件;它只负责构建,不会自动创建 tag 或推送远程仓库。安装包和 `SHA256SUMS.txt` 输出到:
|
`build.sh` 与 `build.bat` 面向 v2.1.x,会检查 Node、npm、Rust target 和五处版本元数据。显式传入不同于当前源码的新版本时,脚本会同步版本文件;它们只负责构建,不会自动创建 tag 或推送远程仓库。安装包和 `SHA256SUMS.txt` 输出到:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
release/v2.1.1/macos-arm64/
|
release/v2.1.1/macos-arm64/
|
||||||
@@ -25,6 +25,27 @@ release/v2.1.1/windows-x64/
|
|||||||
|
|
||||||
平台安装包必须在目标系统构建和验证。macOS 不能替代 Windows x86_64 实机打包。
|
平台安装包必须在目标系统构建和验证。macOS 不能替代 Windows x86_64 实机打包。
|
||||||
|
|
||||||
|
### Windows 构建环境
|
||||||
|
|
||||||
|
建议使用 Windows 10/11 x64,并安装:
|
||||||
|
|
||||||
|
- Node.js 18+ 与 npm
|
||||||
|
- Rust stable 的 `x86_64-pc-windows-msvc` toolchain
|
||||||
|
- Visual Studio 2022 Build Tools,勾选 **Desktop development with C++**
|
||||||
|
- Windows 10/11 SDK
|
||||||
|
- Microsoft Edge WebView2 Runtime
|
||||||
|
|
||||||
|
首次准备:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
rustup default stable-x86_64-pc-windows-msvc
|
||||||
|
rustup target add x86_64-pc-windows-msvc
|
||||||
|
npm ci
|
||||||
|
build.bat v2.1.1 win-x64
|
||||||
|
```
|
||||||
|
|
||||||
|
默认执行完整门禁;`--quick` 适合开发期重复构建,正式发布不要使用 `--skip-checks`。输出位于 `release\v2.1.1\windows-x64\`。没有代码签名证书时可以生成开发安装包,但 Windows SmartScreen 可能提示未知发布者。
|
||||||
|
|
||||||
## Gitee Release
|
## Gitee Release
|
||||||
|
|
||||||
构建、提交并推送对应 tag 后执行:
|
构建、提交并推送对应 tag 后执行:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ npm run acceptance:full
|
|||||||
- API Key 只进入 Rust secrets,不出现在 `ai-providers-v1`、日志或 WebView 返回值中
|
- API Key 只进入 Rust secrets,不出现在 `ai-providers-v1`、日志或 WebView 返回值中
|
||||||
- Agent CLI 单任务入口、回车发送、选区上下文、本地脱敏、单行命令协议和最终结论
|
- Agent CLI 单任务入口、回车发送、选区上下文、本地脱敏、单行命令协议和最终结论
|
||||||
- 新 Provider 测试前不落盘,真实测试成功后才启用保存;任一连接字段变化会使验证快照失效
|
- 新 Provider 测试前不落盘,真实测试成功后才启用保存;任一连接字段变化会使验证快照失效
|
||||||
- Agent 固定当前 SSH connection,每步确认前不得调用 `ssh_exec`,退出码与真实输出反馈下一轮且不进入历史
|
- Agent 固定当前 SSH connection;达到当前安全模式确认阈值时,确认前不得调用 `ssh_exec`;退出码与真实输出反馈下一轮且不进入历史
|
||||||
- Agent 五级命令风险、critical 硬拒绝、连续失败换路、相同命令循环拦截和长输出 `analyze_output`
|
- Agent 五级命令风险、critical 硬拒绝、连续失败换路、相同命令循环拦截和长输出 `analyze_output`
|
||||||
- 远程 Shell EOF/TMOUT 事件不得误判为整个 SSH 连接断开;只为对应分屏提供一键重开
|
- 远程 Shell EOF/TMOUT 事件不得误判为整个 SSH 连接断开;只为对应分屏提供一键重开
|
||||||
- macOS 原生“编辑”菜单以及所有输入框的复制、剪切、粘贴、全选、撤销;应用快捷键不得覆盖这些系统操作
|
- macOS 原生“编辑”菜单以及所有输入框的复制、剪切、粘贴、全选、撤销;应用快捷键不得覆盖这些系统操作
|
||||||
@@ -58,7 +58,7 @@ npm run acceptance:full
|
|||||||
8. models.dev 三个源全部失败时不影响已保存 Provider;部分成功时仍能生成可导入目录。
|
8. models.dev 三个源全部失败时不影响已保存 Provider;部分成功时仍能生成可导入目录。
|
||||||
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
|
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
|
||||||
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
|
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
|
||||||
11. 每一轮只接受唯一单行命令;确认对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。
|
11. 每一轮只接受唯一单行命令;默认均衡模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;宽松模式不允许绕过 critical 硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。
|
||||||
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。
|
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。
|
||||||
13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。
|
13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。
|
||||||
14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。
|
14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。
|
||||||
|
|||||||
+4
-2
@@ -74,10 +74,12 @@ SFTP 使用双栏文件管理器。上传和下载先写入 `.opsx-part` 临时
|
|||||||
|
|
||||||
- `Cmd/Ctrl + D`:分屏当前终端
|
- `Cmd/Ctrl + D`:分屏当前终端
|
||||||
- `Cmd/Ctrl + W`:关闭当前标签
|
- `Cmd/Ctrl + W`:关闭当前标签
|
||||||
|
- 终端右键菜单提供复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭当前分屏;没有选区时“复制”保持禁用,不会一次右键就直接粘贴。
|
||||||
|
- “广播”作用于所有已打开且已连接的 SSH 终端标签和分屏,按钮显示实时目标数;本地 Shell 不加入远程广播。目标不足两个时广播自动关闭。
|
||||||
- 会话侧栏进入批量模式后,可同时打开终端、SFTP,或在多个主机执行同一条命令
|
- 会话侧栏进入批量模式后,可同时打开终端、SFTP,或在多个主机执行同一条命令
|
||||||
- 如果远端 Bash 因 `TMOUT` 显示 `timed out waiting for input: auto-logout`,OpsXShell 会标记只有该 Shell/分屏退出。SSH 连接、其他分屏、SFTP 和隧道仍可用,点击“重新打开 Shell”即可继续。
|
- 如果远端 Bash 因 `TMOUT` 显示 `timed out waiting for input: auto-logout`,OpsXShell 会标记只有该 Shell/分屏退出。SSH 连接、其他分屏、SFTP 和隧道仍可用,点击“重新打开 Shell”即可继续。
|
||||||
|
|
||||||
批量命令结果只保留在当前弹窗。OpsXShell 不持久化终端命令历史。
|
批量命令弹框可以再次搜索、全选或调整主机,执行区逐主机显示连接/运行状态、输出、耗时与总进度。结果只保留在当前弹窗;OpsXShell 不持久化终端命令历史。
|
||||||
|
|
||||||
## 本地终端与串口
|
## 本地终端与串口
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ SFTP 使用双栏文件管理器。上传和下载先写入 `.opsx-part` 临时
|
|||||||
|
|
||||||
- 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。
|
- 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。
|
||||||
- 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。
|
- 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。
|
||||||
- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险;你确认后才真实执行。
|
- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认“均衡”模式仅高风险命令确认;“严格”从中风险开始确认;“宽松”仅保留 critical 硬拒绝,适合隔离测试环境。
|
||||||
- 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。
|
- 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。
|
||||||
- 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。
|
- 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。
|
||||||
- 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。
|
- 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 产品体验与基础能力全面评估
|
||||||
|
|
||||||
|
> 更新:2026-07-31
|
||||||
|
> 目标:OpsXShell 做成“麻雀虽小、五脏俱全”的轻量 SSH/SFTP 工具。常用操作直接、结果明确、失败可恢复,不用花哨概念增加学习成本。
|
||||||
|
|
||||||
|
## 评估结论
|
||||||
|
|
||||||
|
当前开发分支已具备 SSH、SFTP、串口、分屏、批量命令、SOCKS5、跳板和 AI Agent 的完整产品骨架,已经不是功能演示,但距离可长期替代 Tabby、Netcatty、Xshell 或 WindTerm 仍有一段可靠性和跨平台发布差距。
|
||||||
|
|
||||||
|
综合评分为 **7.1/10**。评分针对当前代码和自动化基线,不等于正式安装包质量;Windows 实机、macOS 安装版、断网/休眠和真实大文件传输未统一验收前,不能按成熟商业终端评价。
|
||||||
|
|
||||||
|
| 维度 | 分数 | 判断 |
|
||||||
|
| ------------------ | ---: | ---------------------------------------------------------------------------------- |
|
||||||
|
| 产品聚焦 | 8.5 | SSH/SFTP/代理/串口边界清楚,没有堡垒机、审计和 CMDB 噪音 |
|
||||||
|
| 首次连接与会话管理 | 7.8 | 快速连接和保存会话路径短,支持密码、私钥、代理和跳板 |
|
||||||
|
| 终端基础体验 | 7.2 | 复制粘贴、右键、分屏、广播和 Shell 生命周期已补齐;搜索和高压稳定性仍不足 |
|
||||||
|
| SFTP | 6.8 | 双栏、双向拖拽、上传下载、权限和远程编辑已具备;冲突、重试和任务恢复不足 |
|
||||||
|
| 隧道与 SOCKS5 | 7.5 | 规则与运行实例分离、真实启停和状态统计方向正确;重连和复用还需压测 |
|
||||||
|
| 批量运维 | 7.0 | 多主机选择、并发、逐机结果清楚;取消和独立超时未完成 |
|
||||||
|
| AI Agent | 6.8 | 单入口、当前 SSH 执行、风险模式和 ReAct 基线可用;执行内核仍在 React,真正取消不足 |
|
||||||
|
| 性能与可靠性 | 6.2 | Tauri/Rust 基础占优,但断网、休眠、大输出和频繁切换仍缺持续压测 |
|
||||||
|
| 跨平台发布 | 5.8 | macOS/Windows 构建脚本已具备;Windows 实机、签名、公证、更新和崩溃诊断未闭环 |
|
||||||
|
| 文档与自动化 | 7.8 | 有契约、组件、Rust 和真实探针分层;仍需把实机矩阵纳入每次候选发布 |
|
||||||
|
|
||||||
|
## 产品主旨
|
||||||
|
|
||||||
|
OpsXShell 的默认界面只围绕七件事:连接主机、管理会话、操作终端、传输文件、开隧道、批量执行、必要时让 AI 在当前主机协助处理。
|
||||||
|
|
||||||
|
必须坚持:
|
||||||
|
|
||||||
|
1. 高频动作一步可达,高级选项默认收起。
|
||||||
|
2. 当前操作目标始终可见,尤其是广播、批量命令、SFTP 和 AI 执行。
|
||||||
|
3. 成功、进行中、失败、断开是四种不同状态,不能只靠颜色或开发日志表达。
|
||||||
|
4. 空闲操作不滥用确认框;删除、覆盖、关闭运行任务和高风险 AI 命令才确认。
|
||||||
|
5. 不保存终端命令、广播输入、批量命令、AI 对话或工具输出。
|
||||||
|
6. 不加入审计、录像、审批、CMDB、独立 podexec 或 Kubernetes 凭据。
|
||||||
|
|
||||||
|
## 大众操作习惯基线
|
||||||
|
|
||||||
|
### 鼠标与窗口
|
||||||
|
|
||||||
|
- 单击负责选择和聚焦,双击负责打开会话/目录或切换窗口最大化,不能让一次双击触发两次相反操作。
|
||||||
|
- 右键必须显示与当前位置相关的菜单;普通区域不得出现 WebView 的 Reload/Inspect Element。
|
||||||
|
- macOS 使用原生红黄绿窗口控制,Windows 使用系统最小化/最大化/关闭;标题栏空白区可拖动。
|
||||||
|
- 拖拽必须显示源、目标和允许/禁止状态;文件落到目录行时传入该目录,落到空白区时传入当前目录。
|
||||||
|
|
||||||
|
### 键盘
|
||||||
|
|
||||||
|
| 动作 | macOS | Windows/Linux |
|
||||||
|
| -------------------- | -------------- | ------------------------------------------------- |
|
||||||
|
| 复制/粘贴终端 | `Cmd+C/V` | `Ctrl+Shift+C/V` |
|
||||||
|
| 普通输入框复制/粘贴 | `Cmd+C/V` | `Ctrl+C/V` |
|
||||||
|
| 新建会话 | `Cmd+N` | `Ctrl+N` |
|
||||||
|
| 快速连接 | `Cmd+K` | `Ctrl+K` |
|
||||||
|
| 关闭标签 | `Cmd+W` | `Ctrl+W` |
|
||||||
|
| 分屏 | `Cmd+D` | `Ctrl+D`,但必须避免与远端 EOF 语义冲突并允许改键 |
|
||||||
|
| 终端搜索 | `Cmd+F` | `Ctrl+F` |
|
||||||
|
| 开发工具(开发构建) | `Cmd+Option+I` | `F12` |
|
||||||
|
|
||||||
|
- 快捷键不能抢占输入框的复制、粘贴、全选、撤销和输入法组合状态。
|
||||||
|
- 菜单和提示必须显示当前平台实际快捷键,不能同时展示两套组合键让用户猜。
|
||||||
|
- `Escape` 关闭当前最上层弹框/菜单;`Enter` 触发主动作,文本域需要明确换行组合键。
|
||||||
|
|
||||||
|
### 表单
|
||||||
|
|
||||||
|
- SSH 目标允许直接粘贴 `user@host:port`,端口默认 22。
|
||||||
|
- 保存会话只展示“密码/密钥”两个常用认证入口;代理、跳板、标签和启动命令放在高级区。
|
||||||
|
- 端口、路径和 Endpoint 在失焦或提交时给出就地错误,不能无提示恢复默认值。
|
||||||
|
- 取消或重新打开快速连接、批量命令、AI 面板时,敏感输入必须清空。
|
||||||
|
- 按钮文案说明结果,如“连接”“创建并保存”“执行到 3 台”,避免只显示含义不明的图标。
|
||||||
|
|
||||||
|
### 状态与错误
|
||||||
|
|
||||||
|
- 错误必须包含“什么失败、作用于谁、如何恢复”,例如“Host A 命令超时,可重试”,而不是 `unknown`。
|
||||||
|
- 剪贴板失败、广播目标变为 0、Shell 已退出、隧道端口占用和 SFTP 冲突必须在界面提示。
|
||||||
|
- 重试不能重复创建连接、Shell、SFTP 通道或 Listener;状态必须以 Rust 真实资源为准。
|
||||||
|
- 加载超过 300ms 显示进行中,超过设置超时显示具体阶段;不能无限转圈。
|
||||||
|
|
||||||
|
## 核心用户旅程
|
||||||
|
|
||||||
|
| 旅程 | 目标操作长度 | 当前状态 | 验收标准 |
|
||||||
|
| ----------- | ----------------------------: | -------------- | ------------------------------------------------------- |
|
||||||
|
| 临时连接 | 快捷键 → 粘贴 → 回车 | 已实现 | `user@host:port` 可粘贴;取消重开后密码为空 |
|
||||||
|
| 保存会话 | 新建 → 目标/凭据 → 保存 | 已实现 | 默认 22;常用项不超过一屏;错误就地显示 |
|
||||||
|
| 重复连接 | 双击侧栏会话 | 已实现 | 单击只选择,双击只打开一个终端标签 |
|
||||||
|
| 终端操作 | 右键或快捷键 | 已实现 | 复制、粘贴、全选、清屏、分屏、最大化、关闭均明确 |
|
||||||
|
| 多终端广播 | 开广播 → 输入 | 已实现,待增强 | 覆盖所有打开 SSH Shell,显示目标数,本地 Shell 永不参与 |
|
||||||
|
| 批量命令 | 选主机 → 命令 → 执行 | 已实现,待增强 | 最多并发 5,逐机结果和耗时;关闭后命令销毁 |
|
||||||
|
| SFTP | 会话右键 → SFTP 文件 | 已实现,待实机 | 本地↔远程拖拽、按钮和右键都走明确 upload/download 方向 |
|
||||||
|
| SOCKS5 | 会话 → 转发 → 启动 | 已实现,待压测 | 默认 `127.0.0.1:1080`;停止后真实端口不可连接 |
|
||||||
|
| 串口 | 快速启动 → 串口 → 参数 → 连接 | 已实现,待实机 | 可枚举设备,默认常用参数,断开和重连状态明确 |
|
||||||
|
| AI 处理任务 | 当前 SSH → AI → 描述 → 执行 | 实验可用 | 目标固定、安全模式明确、critical 硬拒、关闭后上下文销毁 |
|
||||||
|
|
||||||
|
## 当前已完成的基础修正
|
||||||
|
|
||||||
|
- 全局广播覆盖所有打开且已连接的 SSH 标签与分屏,排除本地 Shell;目标不足两个时自动关闭。
|
||||||
|
- 终端右键菜单包含复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭。
|
||||||
|
- 关闭分屏会调用 Rust `ssh_close_shell`,远端关闭失败会记录明确错误,不只移除前端格子。
|
||||||
|
- 远端 Shell 的 EOF、`TMOUT` 和 `Ctrl+D` 不再等同整个 SSH 连接断开,可只重开当前 Shell。
|
||||||
|
- 批量命令支持搜索、全选当前结果、调整目标、最大并发 5、逐机状态/输出/耗时和总进度。
|
||||||
|
- 快速连接和批量命令重新打开会清空敏感输入,结果不落盘。
|
||||||
|
- Agent 支持严格、均衡、宽松三级安全模式;默认均衡只确认 high,critical 永久硬拒绝。
|
||||||
|
- 普通 WebView 区域禁止 Reload/Inspect Element,文本输入保留系统编辑菜单。
|
||||||
|
- 应用版本由 `package.json` 构建时注入,不再在状态栏硬编码旧版本。
|
||||||
|
|
||||||
|
## P0:下一候选版本必须完成
|
||||||
|
|
||||||
|
| ID | 优化项 | 当前缺口 | 完成标准 |
|
||||||
|
| ----- | --------------------- | -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| P0-01 | 连接生命周期压测 | 断网、睡眠唤醒、快速切标签和连接复用证据不足 | 自动覆盖连接/重连/挂起/恢复;100 次切换无重复连接和监听器泄漏 |
|
||||||
|
| P0-02 | 终端搜索接通 | `SearchAddon` 已加载,但分屏工作区没有可用搜索 UI | `Cmd/Ctrl+F` 搜索当前焦点分屏,支持上一项/下一项/关闭 |
|
||||||
|
| P0-03 | 终端高输出稳定性 | 仍出现过 CPU 高占用和操作卡顿 | 持续输出 10 分钟 UI 可操作;后台标签限流;CPU/内存有基准阈值 |
|
||||||
|
| P0-04 | 用户可见 I/O 错误 | 部分剪贴板、关闭 Shell 和事件错误只写日志 | toast/覆盖层说明失败对象和恢复动作;不得产生未处理 Promise |
|
||||||
|
| P0-05 | SFTP 基础传输实机门禁 | 自动测试有方向契约和探针,但真实安装版仍需重复验证 | 上传、下载、覆盖、目录落点、拖拽、取消后临时文件处理全部通过 |
|
||||||
|
| P0-06 | SFTP 冲突规则 | 同名文件处理不够显式 | 每次任务可选择覆盖/跳过/重命名;目录递归不静默覆盖 |
|
||||||
|
| P0-07 | 批量任务超时与取消 | 当前只能等待 worker 完成 | 区分连接/命令超时;停止后不再启动新主机,当前命令标为等待完成 |
|
||||||
|
| P0-08 | 隧道重连与清理 | Listener 真实启停已做,网络恢复策略不足 | 主连接断开立即清理;重连后按用户选择恢复;端口冲突显示占用地址 |
|
||||||
|
| P0-09 | 最小窗口适配 | 960×720 基础可用,AI 打开后终端区域偏窄 | 960×640 无遮挡;打开 AI 时可自动收起侧栏或提供一键扩展 |
|
||||||
|
| P0-10 | 双平台安装验收 | macOS 结果不能代表 Windows | macOS arm64 与 Windows x86_64 分别完成安装、升级、卸载和核心冒烟 |
|
||||||
|
|
||||||
|
## P1:提升日常效率
|
||||||
|
|
||||||
|
| ID | 优化项 | 建议 |
|
||||||
|
| ----- | --------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| P1-01 | 广播目标预览 | 点击目标数查看主机/分屏,允许本次临时排除;默认仍是全部打开 SSH |
|
||||||
|
| P1-02 | 批量失败重试 | 一键仅重试失败主机,可从分组/标签建立目标集合 |
|
||||||
|
| P1-03 | 终端标题与路径 | 安全解析 OSC 标题/当前目录,允许锁定会话名,防止远端伪装安全提示 |
|
||||||
|
| P1-04 | 关闭保护 | 仅当前台有运行任务时提示关闭会结束远端 Shell;空闲时一步关闭 |
|
||||||
|
| P1-05 | SFTP 任务中心 | 统一暂停/继续/取消/重试,显示速度、剩余时间和失败原因;重启后可恢复未完成任务 |
|
||||||
|
| P1-06 | SFTP 易用性 | 多选拖拽、复制路径、在终端打开目录、远程编辑冲突提示、校验和验证 |
|
||||||
|
| P1-07 | SSH Config 导入 | 展示将导入字段、冲突处理和不可支持项;支持 ProxyJump 链预览 |
|
||||||
|
| P1-08 | 隧道便捷入口 | 状态栏一键查看/复制 SOCKS5 地址;复制规则永不复制运行态 |
|
||||||
|
| P1-09 | Agent Runtime | 从 React 抽离状态机,增加单步超时、真正取消、结构化 tool calling 和 eval |
|
||||||
|
| P1-10 | 无障碍 | 所有图标按钮有名称;焦点圈清楚;弹框焦点锁定/返回;状态不只靠颜色 |
|
||||||
|
|
||||||
|
## P2:专业兼容与发布闭环
|
||||||
|
|
||||||
|
- Agent Forwarding、OpenSSH 证书、FIDO2 和更完整的算法策略。
|
||||||
|
- 保存常用分屏布局,但不保存 Shell ID、命令或终端输出。
|
||||||
|
- 隧道使用明确的共享连接/Multiplexer 策略,避免每个请求重新握手。
|
||||||
|
- macOS Developer ID 签名与公证、Windows Authenticode、自动更新清单和回滚。
|
||||||
|
- 用户主动导出的脱敏诊断包:版本、平台、错误码、资源统计,不包含命令、密码和终端输出。
|
||||||
|
- 可选主题和键位导入;不为了生态而引入重量级插件框架。
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
- 不恢复命令历史,不持久化广播、批量命令或 Agent 输入输出。
|
||||||
|
- 不增加堡垒机审计、审批流、录像、CMDB、告警大盘或独立 podexec。
|
||||||
|
- 不使用“有选区右键复制、无选区右键直接粘贴”这类隐藏行为。
|
||||||
|
- 不让本地 Shell 加入远程广播,不允许 `ssh://` 携带密码或静默连接。
|
||||||
|
- 不为“专业感”堆无用途状态卡、动画、统计图和多级配置。
|
||||||
|
|
||||||
|
## 统一回归场景
|
||||||
|
|
||||||
|
1. 快速连接粘贴 `root@opsbase.cn:55555`,取消后重开,目标和密码为空;通过 `ssh://` 唤起时只预填安全目标。
|
||||||
|
2. 新建会话默认端口 22,密码/密钥切换不会把另一种凭据落盘;编辑高级配置不影响基础字段。
|
||||||
|
3. 打开两个 SSH 标签,其中一个分两屏;广播输入 `date`,三个远端各收到一次,本地 Shell 不收到。
|
||||||
|
4. 关闭一个分屏,Rust Shell 释放,广播目标立即减少,兄弟分屏、SFTP 和隧道保持可用。
|
||||||
|
5. 终端右键复制、粘贴、全选、清屏、向右/向下分屏、最大化/恢复和关闭均可用,无 Reload/Inspect Element。
|
||||||
|
6. `Cmd/Ctrl+F` 只搜索当前焦点分屏;切换分屏后结果随焦点切换。
|
||||||
|
7. 批量选择两台执行 `date`,逐机显示耗时和结果;停止后不启动剩余主机;关闭重开命令为空。
|
||||||
|
8. SFTP 按钮上传、右键上传、本地拖到远程、远程拖到本地结果一致;文件逐字节校验成功。
|
||||||
|
9. 同名文件明确选择覆盖/跳过/重命名;失败不留下被误认为完整文件的目标。
|
||||||
|
10. 启动 `127.0.0.1:1080` SOCKS5,代理请求成功且统计变化;停止后端口不可连接,重启应用显示未运行。
|
||||||
|
11. 远端 `TMOUT` 只关闭当前 Shell,一键重开可用,SSH connection 和其他资源不被误断。
|
||||||
|
12. Agent 均衡模式自动执行 `df -h`,`systemctl restart nginx` 先确认;严格模式修改文件先确认;宽松仍拒绝 `rm -rf /`。
|
||||||
|
13. 断网后状态由“已连接”进入“重连中/失败”,恢复后不重复创建标签、连接、Shell 或 Listener。
|
||||||
|
14. macOS 窗口可拖动,双击标题栏只切换一次最大化;Windows 系统按钮、快捷键和安装卸载符合平台习惯。
|
||||||
|
15. 960×640、125%/150% Windows 缩放以及长主机名、中文分组下,无按钮遮挡、水平溢出或不可达操作。
|
||||||
|
|
||||||
|
## 发版判定
|
||||||
|
|
||||||
|
自动化全绿只代表代码门禁通过。候选发布还必须分别在 macOS arm64 和 Windows x86_64 完成上述核心旅程,并记录版本、安装包哈希、测试平台、失败项和回归结果。任何“只能偶现”“重试一般能好”的连接、终端输入、SFTP 或隧道问题都按未通过处理。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
@@ -20,6 +20,17 @@ test('build.sh is valid Bash and documents supported native targets', () => {
|
|||||||
assert.match(help.stdout, /不会创建或推送 Git tag/)
|
assert.match(help.stdout, /不会创建或推送 Git tag/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('build.bat provides an equivalent native Windows x64 release path', () => {
|
||||||
|
const script = readFileSync(resolve(root, 'build.bat'), 'utf8')
|
||||||
|
assert.match(script, /x86_64-pc-windows-msvc/)
|
||||||
|
assert.match(script, /acceptance:full/)
|
||||||
|
assert.match(script, /release-preflight\.mjs windows/)
|
||||||
|
assert.match(script, /verify-release\.mjs windows/)
|
||||||
|
assert.match(script, /nsis\\\*\.exe/)
|
||||||
|
assert.match(script, /SHA256SUMS\.txt/)
|
||||||
|
assert.match(script, /不会创建或推送 Git tag/)
|
||||||
|
})
|
||||||
|
|
||||||
test('release metadata versions stay synchronized', () => {
|
test('release metadata versions stay synchronized', () => {
|
||||||
const versions = Object.values(inspectVersions(root))
|
const versions = Object.values(inspectVersions(root))
|
||||||
assert.ok(versions.length > 0)
|
assert.ok(versions.length > 0)
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export const IPC_CHANNELS = {
|
|||||||
SSH_CREATE_SHELL: 'ssh:createShell',
|
SSH_CREATE_SHELL: 'ssh:createShell',
|
||||||
SSH_SHELL_WRITE: 'ssh:shellWrite',
|
SSH_SHELL_WRITE: 'ssh:shellWrite',
|
||||||
SSH_SHELL_RESIZE: 'ssh:shellResize',
|
SSH_SHELL_RESIZE: 'ssh:shellResize',
|
||||||
|
SSH_SHELL_CLOSE: 'ssh:shellClose',
|
||||||
SSH_FORWARD_LOCAL: 'ssh:forwardLocal',
|
SSH_FORWARD_LOCAL: 'ssh:forwardLocal',
|
||||||
SSH_FORWARD_REMOTE: 'ssh:forwardRemote',
|
SSH_FORWARD_REMOTE: 'ssh:forwardRemote',
|
||||||
SSH_FORWARD_DYNAMIC: 'ssh:forwardDynamic',
|
SSH_FORWARD_DYNAMIC: 'ssh:forwardDynamic',
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ pub fn ssh_shell_resize(
|
|||||||
operation(state.ssh.shell_resize(&shell_id, cols, rows))
|
operation(state.ssh.shell_resize(&shell_id, cols, rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ssh_close_shell(state: State<'_, AppState>, shell_id: String) -> OperationResponse {
|
||||||
|
operation(state.ssh.close_shell(&shell_id))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sftp_connect(state: State<'_, AppState>, connection_id: String) -> IdResponse {
|
pub fn sftp_connect(state: State<'_, AppState>, connection_id: String) -> IdResponse {
|
||||||
match state.ssh.create_sftp(&connection_id) {
|
match state.ssh.create_sftp(&connection_id) {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ pub fn run() {
|
|||||||
commands::ssh::ssh_create_shell,
|
commands::ssh::ssh_create_shell,
|
||||||
commands::ssh::ssh_shell_write,
|
commands::ssh::ssh_shell_write,
|
||||||
commands::ssh::ssh_shell_resize,
|
commands::ssh::ssh_shell_resize,
|
||||||
|
commands::ssh::ssh_close_shell,
|
||||||
commands::ssh::sftp_connect,
|
commands::ssh::sftp_connect,
|
||||||
commands::ssh::sftp_list_directory,
|
commands::ssh::sftp_list_directory,
|
||||||
commands::ssh::sftp_upload,
|
commands::ssh::sftp_upload,
|
||||||
|
|||||||
@@ -212,6 +212,19 @@ impl SshService {
|
|||||||
.map_err(|error| format!("调整远程终端尺寸失败: {error}"))
|
.map_err(|error| format!("调整远程终端尺寸失败: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn close_shell(&self, shell_id: &str) -> Result<(), String> {
|
||||||
|
let mut shell = self
|
||||||
|
.shells
|
||||||
|
.lock()
|
||||||
|
.map_err(lock_error)?
|
||||||
|
.remove(shell_id)
|
||||||
|
.ok_or_else(|| format!("远程 Shell 不存在: {shell_id}"))?;
|
||||||
|
let _ = shell.channel.send_eof();
|
||||||
|
let _ = shell.channel.close();
|
||||||
|
let _ = shell.session.disconnect(None, "shell closed by user", None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn forward_local(
|
pub fn forward_local(
|
||||||
&self,
|
&self,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Server,
|
Server,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Square,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -15,8 +17,16 @@ import { useAiProviderStore } from '../../stores/aiProviderStore'
|
|||||||
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
|
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
|
||||||
import { writeClipboardText } from '../../platform/clipboard'
|
import { writeClipboardText } from '../../platform/clipboard'
|
||||||
import { analyzeAgentOutput } from '../../utils/agentOutput'
|
import { analyzeAgentOutput } from '../../utils/agentOutput'
|
||||||
import { AGENT_RISK_LABEL, assessAgentCommandRisk, type AgentCommandRisk } from '../../utils/agentCommandRisk'
|
import {
|
||||||
|
AGENT_RISK_LABEL,
|
||||||
|
AGENT_SAFETY_MODES,
|
||||||
|
agentCommandNeedsConfirmation,
|
||||||
|
assessAgentCommandRisk,
|
||||||
|
type AgentCommandRisk,
|
||||||
|
type AgentSafetyMode,
|
||||||
|
} from '../../utils/agentCommandRisk'
|
||||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||||
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
|
|
||||||
export interface TerminalAiContext {
|
export interface TerminalAiContext {
|
||||||
selection: string
|
selection: string
|
||||||
@@ -43,6 +53,7 @@ interface AgentStep {
|
|||||||
const MAX_AGENT_STEPS = 8
|
const MAX_AGENT_STEPS = 8
|
||||||
const MAX_TOOL_OUTPUT_CHARS = 12_000
|
const MAX_TOOL_OUTPUT_CHARS = 12_000
|
||||||
const MAX_AGENT_CONTEXT_CHARS = 48_000
|
const MAX_AGENT_CONTEXT_CHARS = 48_000
|
||||||
|
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'balanced', 'permissive']
|
||||||
|
|
||||||
function truncateMiddle(value: string, limit: number): string {
|
function truncateMiddle(value: string, limit: number): string {
|
||||||
if (value.length <= limit) return value
|
if (value.length <= limit) return value
|
||||||
@@ -102,6 +113,8 @@ export default function AiTerminalPanel({
|
|||||||
isLocal = false,
|
isLocal = false,
|
||||||
}: AiTerminalPanelProps) {
|
}: AiTerminalPanelProps) {
|
||||||
const store = useAiProviderStore()
|
const store = useAiProviderStore()
|
||||||
|
const safetyMode = useSettingsStore((state) => state.settings.agentSafetyMode)
|
||||||
|
const setSetting = useSettingsStore((state) => state.setSetting)
|
||||||
const [prompt, setPrompt] = useState('')
|
const [prompt, setPrompt] = useState('')
|
||||||
const [result, setResult] = useState('')
|
const [result, setResult] = useState('')
|
||||||
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
|
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
|
||||||
@@ -150,6 +163,7 @@ export default function AiTerminalPanel({
|
|||||||
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
|
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
|
||||||
)
|
)
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
|
const runSafetyMode = safetyMode
|
||||||
|
|
||||||
const completedSteps: AgentStep[] = []
|
const completedSteps: AgentStep[] = []
|
||||||
const commandCounts = new Map<string, number>()
|
const commandCounts = new Map<string, number>()
|
||||||
@@ -204,21 +218,24 @@ export default function AiTerminalPanel({
|
|||||||
if (risk === 'critical') {
|
if (risk === 'critical') {
|
||||||
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
|
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
|
||||||
}
|
}
|
||||||
const awaiting: AgentStep = { round, command, risk, status: 'awaiting' }
|
const proposed: AgentStep = { round, command, risk, status: 'running' }
|
||||||
setSteps([...completedSteps, awaiting])
|
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
|
||||||
const approved = await confirm({
|
const awaiting = { ...proposed, status: 'awaiting' as const }
|
||||||
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
|
setSteps([...completedSteps, awaiting])
|
||||||
message: `目标:SSH · ${targetLabel}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
|
const approved = await confirm({
|
||||||
danger: risk === 'high',
|
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
|
||||||
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
|
message: `目标:SSH · ${targetLabel}\n安全模式:${AGENT_SAFETY_MODES[runSafetyMode].label}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
|
||||||
})
|
danger: risk === 'high',
|
||||||
if (!approved) {
|
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
|
||||||
setSteps([...completedSteps])
|
})
|
||||||
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
|
if (!approved) {
|
||||||
return
|
setSteps([...completedSteps])
|
||||||
|
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSteps([...completedSteps, { ...awaiting, status: 'running' }])
|
setSteps([...completedSteps, proposed])
|
||||||
const executed = await window.opsxApi.sshExec(connectionId, command)
|
const executed = await window.opsxApi.sshExec(connectionId, command)
|
||||||
if (runIdRef.current !== runId) return
|
if (runIdRef.current !== runId) return
|
||||||
const rawOutput = executed.success
|
const rawOutput = executed.success
|
||||||
@@ -259,8 +276,14 @@ export default function AiTerminalPanel({
|
|||||||
window.setTimeout(() => setCopied(false), 1200)
|
window.setTimeout(() => setCopied(false), 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopAgent = () => {
|
||||||
|
runIdRef.current += 1
|
||||||
|
setRunning(false)
|
||||||
|
setResult('任务已停止;正在执行的单条 SSH 命令不会被强制中断,但不会再继续下一步。')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="border-dark-700/50 bg-dark-850 flex w-[390px] flex-shrink-0 flex-col border-l">
|
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
|
||||||
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
|
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
|
||||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -269,6 +292,9 @@ export default function AiTerminalPanel({
|
|||||||
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
|
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="rounded-md border border-dark-600 bg-dark-900/70 px-2 py-1 text-[10px] text-content-secondary">
|
||||||
|
{AGENT_SAFETY_MODES[safetyMode].label}
|
||||||
|
</span>
|
||||||
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
|
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -282,7 +308,40 @@ export default function AiTerminalPanel({
|
|||||||
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
|
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] leading-relaxed text-txt-faint">
|
<p className="text-[10px] leading-relaxed text-txt-faint">
|
||||||
Agent 通过当前连接执行、观察再决定下一步;每条真实命令都需要你确认。
|
Agent 只使用当前 SSH 连接执行、观察并继续处理,不切换目标主机。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dark-700/60 bg-dark-900/45 p-2.5">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-content-secondary">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-accent-green" /> 执行安全模式
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1" role="radiogroup" aria-label="Agent 安全模式">
|
||||||
|
{SAFETY_MODE_ORDER.map((mode) => {
|
||||||
|
const option = AGENT_SAFETY_MODES[mode]
|
||||||
|
const selected = safetyMode === mode
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={running}
|
||||||
|
title={`${option.short}:${option.description}`}
|
||||||
|
onClick={() => void setSetting('agentSafetyMode', mode)}
|
||||||
|
className={`rounded-md border px-2 py-1.5 text-[11px] transition-colors disabled:opacity-50 ${
|
||||||
|
selected
|
||||||
|
? 'border-accent-blue/50 bg-accent-blue/15 text-accent-blue'
|
||||||
|
: 'border-dark-700 bg-dark-800 text-txt-muted hover:text-content-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] leading-relaxed text-txt-faint">
|
||||||
|
{AGENT_SAFETY_MODES[safetyMode].short} · {AGENT_SAFETY_MODES[safetyMode].description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -302,15 +361,15 @@ export default function AiTerminalPanel({
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
|
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
|
||||||
<button
|
{running ? (
|
||||||
type="button"
|
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex min-w-20 items-center justify-center gap-1.5">
|
||||||
onClick={() => void runAgent()}
|
<Square className="h-3.5 w-3.5" /> 停止
|
||||||
disabled={running}
|
</button>
|
||||||
className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5"
|
) : (
|
||||||
>
|
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5">
|
||||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
<Play className="h-3.5 w-3.5" /> 执行
|
||||||
{running ? '执行中…' : '执行'}
|
</button>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-txt-faint">{contextNote}</div>
|
<div className="text-[10px] text-txt-faint">{contextNote}</div>
|
||||||
|
|
||||||
@@ -377,7 +436,7 @@ export default function AiTerminalPanel({
|
|||||||
|
|
||||||
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
|
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
|
||||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||||
最多 {MAX_AGENT_STEPS} 步,重复命令自动拦截;命令、AI 对话和工具输出只存在于当前面板内存。
|
默认均衡模式仅高风险确认;critical 永久拒绝。最多 {MAX_AGENT_STEPS} 步,命令、对话和输出不落盘。
|
||||||
</footer>
|
</footer>
|
||||||
{ConfirmDialogComponent}
|
{ConfirmDialogComponent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -429,7 +429,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
{ConfirmDialogComponent}
|
{ConfirmDialogComponent}
|
||||||
<BatchCommandDialog
|
<BatchCommandDialog
|
||||||
isOpen={batchCommandOpen}
|
isOpen={batchCommandOpen}
|
||||||
sessions={batchCommandSessions}
|
sessions={sessions}
|
||||||
|
initialSelectedIds={batchCommandSessions.map((session) => session.id)}
|
||||||
onClose={() => setBatchCommandOpen(false)}
|
onClose={() => setBatchCommandOpen(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePoolStore } from '../../stores/connection/poolStore'
|
|||||||
import type { ConnectionState } from '../../stores/connection'
|
import type { ConnectionState } from '../../stores/connection'
|
||||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||||
import { useSessionStore } from '../../stores'
|
import { useSessionStore } from '../../stores'
|
||||||
|
import packageJson from '../../../package.json'
|
||||||
|
|
||||||
// ============== Status Display Config ==============
|
// ============== Status Display Config ==============
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionSta
|
|||||||
|
|
||||||
export default function StatusBar() {
|
export default function StatusBar() {
|
||||||
const togglePanel = useConnectionPanelStore((s) => s.toggle)
|
const togglePanel = useConnectionPanelStore((s) => s.toggle)
|
||||||
const [version, setVersion] = useState('1.0.0')
|
const [version, setVersion] = useState(packageJson.version)
|
||||||
const [currentTime, setCurrentTime] = useState(new Date())
|
const [currentTime, setCurrentTime] = useState(new Date())
|
||||||
|
|
||||||
// Tab store
|
// Tab store
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { AlertCircle, CheckCircle2, Clock3, Loader2, Play, TerminalSquare } from 'lucide-react'
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
TerminalSquare,
|
||||||
|
} from 'lucide-react'
|
||||||
import type { Session } from '../../stores/sessionStore'
|
import type { Session } from '../../stores/sessionStore'
|
||||||
import { getSessionConfig } from '../../stores/connection'
|
import { getSessionConfig } from '../../stores/connection'
|
||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
@@ -19,21 +29,51 @@ interface BatchCommandResult {
|
|||||||
interface BatchCommandDialogProps {
|
interface BatchCommandDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
sessions: Session[]
|
sessions: Session[]
|
||||||
|
initialSelectedIds?: string[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CONCURRENCY = 5
|
const MAX_CONCURRENCY = 5
|
||||||
|
const EMPTY_SELECTION: string[] = []
|
||||||
|
|
||||||
export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchCommandDialogProps) {
|
export default function BatchCommandDialog({
|
||||||
|
isOpen,
|
||||||
|
sessions,
|
||||||
|
initialSelectedIds = EMPTY_SELECTION,
|
||||||
|
onClose,
|
||||||
|
}: BatchCommandDialogProps) {
|
||||||
const [command, setCommand] = useState('')
|
const [command, setCommand] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [running, setRunning] = useState(false)
|
const [running, setRunning] = useState(false)
|
||||||
const [results, setResults] = useState<BatchCommandResult[]>([])
|
const [results, setResults] = useState<BatchCommandResult[]>([])
|
||||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
const available = new Set(sessions.map((session) => session.id))
|
||||||
}, [isOpen, sessions])
|
const requested = initialSelectedIds.filter((id) => available.has(id))
|
||||||
|
const initial = requested.length > 0 ? requested : sessions.map((session) => session.id)
|
||||||
|
setSelectedIds(new Set(initial))
|
||||||
|
setResults(initial.map((sessionId) => ({ sessionId, status: 'idle' })))
|
||||||
|
setCommand('')
|
||||||
|
setQuery('')
|
||||||
|
}, [initialSelectedIds, isOpen, sessions])
|
||||||
|
|
||||||
|
const filteredSessions = useMemo(() => {
|
||||||
|
const normalized = query.trim().toLowerCase()
|
||||||
|
if (!normalized) return sessions
|
||||||
|
return sessions.filter((session) =>
|
||||||
|
[session.name, session.host, session.username, session.group, ...(session.tags || [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(normalized))
|
||||||
|
)
|
||||||
|
}, [query, sessions])
|
||||||
|
|
||||||
|
const selectedSessions = useMemo(
|
||||||
|
() => sessions.filter((session) => selectedIds.has(session.id)),
|
||||||
|
[selectedIds, sessions]
|
||||||
|
)
|
||||||
|
|
||||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||||
setResults((current) => current.map((result) =>
|
setResults((current) => current.map((result) =>
|
||||||
@@ -75,23 +115,22 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (connectionId) {
|
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||||
await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [timeout, updateResult])
|
}, [timeout, updateResult])
|
||||||
|
|
||||||
const execute = useCallback(async () => {
|
const execute = useCallback(async () => {
|
||||||
const commandText = command.trim()
|
const commandText = command.trim()
|
||||||
if (!commandText || running || sessions.length === 0) return
|
if (!commandText || running || selectedSessions.length === 0) return
|
||||||
|
|
||||||
|
const targets = [...selectedSessions]
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
setResults(targets.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||||
|
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (nextIndex < sessions.length) {
|
while (nextIndex < targets.length) {
|
||||||
const session = sessions[nextIndex]
|
const session = targets[nextIndex]
|
||||||
nextIndex += 1
|
nextIndex += 1
|
||||||
await executeForSession(session, commandText)
|
await executeForSession(session, commandText)
|
||||||
}
|
}
|
||||||
@@ -99,40 +138,66 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: Math.min(MAX_CONCURRENCY, sessions.length) }, () => worker())
|
Array.from({ length: Math.min(MAX_CONCURRENCY, targets.length) }, () => worker())
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
}
|
}
|
||||||
}, [command, executeForSession, running, sessions])
|
}, [command, executeForSession, running, selectedSessions])
|
||||||
|
|
||||||
|
const toggleSession = (sessionId: string) => {
|
||||||
|
if (running) return
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(sessionId)) next.delete(sessionId)
|
||||||
|
else next.add(sessionId)
|
||||||
|
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFilteredSelected = filteredSessions.length > 0 && filteredSessions.every((session) => selectedIds.has(session.id))
|
||||||
|
const toggleFiltered = () => {
|
||||||
|
if (running) return
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
for (const session of filteredSessions) {
|
||||||
|
if (allFilteredSelected) next.delete(session.id)
|
||||||
|
else next.add(session.id)
|
||||||
|
}
|
||||||
|
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const summary = useMemo(() => ({
|
const summary = useMemo(() => ({
|
||||||
success: results.filter((result) => result.status === 'success').length,
|
success: results.filter((result) => result.status === 'success').length,
|
||||||
error: results.filter((result) => result.status === 'error').length,
|
error: results.filter((result) => result.status === 'error').length,
|
||||||
|
running: results.filter((result) => result.status === 'running').length,
|
||||||
}), [results])
|
}), [results])
|
||||||
|
const completed = summary.success + summary.error
|
||||||
const handleClose = () => {
|
const progress = selectedSessions.length > 0 ? Math.round((completed / selectedSessions.length) * 100) : 0
|
||||||
if (!running) onClose()
|
const handleClose = () => { if (!running) onClose() }
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
title="批量命令"
|
title="批量命令"
|
||||||
description={`${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
description={`已选 ${selectedSessions.length}/${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||||
icon={<TerminalSquare className="h-5 w-5" />}
|
icon={<TerminalSquare className="h-5 w-5" />}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
showFooter
|
showFooter
|
||||||
showConfirm={false}
|
showConfirm={false}
|
||||||
disableBackdropClose={running}
|
disableBackdropClose={running}
|
||||||
size="full"
|
size="full"
|
||||||
|
className="max-w-5xl"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full items-center justify-between gap-3">
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
<span className="text-xs text-txt-muted">
|
<div className="min-w-0 text-xs text-txt-muted">
|
||||||
{summary.success > 0 && <span className="text-accent-green">成功 {summary.success}</span>}
|
{running ? `执行中 ${completed}/${selectedSessions.length}` : `目标 ${selectedSessions.length} 台`}
|
||||||
{summary.success > 0 && summary.error > 0 && ' · '}
|
{summary.success > 0 && <span className="ml-2 text-accent-green">成功 {summary.success}</span>}
|
||||||
{summary.error > 0 && <span className="text-red-400">失败 {summary.error}</span>}
|
{summary.error > 0 && <span className="ml-2 text-red-400">失败 {summary.error}</span>}
|
||||||
</span>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -140,22 +205,21 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
|||||||
size="sm"
|
size="sm"
|
||||||
icon={<Play className="h-3.5 w-3.5" />}
|
icon={<Play className="h-3.5 w-3.5" />}
|
||||||
onClick={() => void execute()}
|
onClick={() => void execute()}
|
||||||
disabled={!command.trim()}
|
disabled={!command.trim() || selectedSessions.length === 0}
|
||||||
loading={running}
|
loading={running}
|
||||||
>
|
>
|
||||||
{results.some((result) => result.status === 'success' || result.status === 'error')
|
{completed > 0 ? '再次执行' : `执行到 ${selectedSessions.length} 台`}
|
||||||
? '再次执行'
|
|
||||||
: '执行命令'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<section className="rounded-xl border border-dark-700/70 bg-dark-900/55 p-3">
|
||||||
<label htmlFor="batch-command" className="mb-1.5 block text-xs font-medium text-content-secondary">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
远程命令
|
<label htmlFor="batch-command" className="text-xs font-medium text-content-secondary">远程命令</label>
|
||||||
</label>
|
<span className="text-[10px] text-txt-faint">Ctrl/⌘ + Enter 执行</span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="batch-command"
|
id="batch-command"
|
||||||
value={command}
|
value={command}
|
||||||
@@ -171,39 +235,101 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
|||||||
autoFocus
|
autoFocus
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder="例如:date"
|
placeholder="例如:date"
|
||||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-950 px-3 py-2.5 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
{(running || completed > 0) && (
|
||||||
|
<div className="mt-2 h-1 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<div className="h-full rounded-full bg-accent-blue transition-[width]" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="max-h-[48vh] space-y-2 overflow-y-auto pr-1">
|
<div className="grid min-h-[390px] grid-cols-[300px_minmax(0,1fr)] gap-3">
|
||||||
{sessions.map((session) => {
|
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
|
||||||
const result = results.find((item) => item.sessionId === session.id)
|
<div className="space-y-2 border-b border-dark-700/60 p-3">
|
||||||
const status = result?.status ?? 'idle'
|
<div className="flex items-center justify-between gap-2">
|
||||||
return (
|
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
|
||||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/65">
|
<Server className="h-3.5 w-3.5" /> 目标主机
|
||||||
<div className="flex min-h-10 items-center gap-2 border-b border-dark-700/70 px-3 py-2">
|
|
||||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
|
||||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
|
||||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
|
||||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content-primary">{session.name}</span>
|
|
||||||
<span className="truncate font-mono text-[11px] text-txt-muted">
|
|
||||||
{session.username}@{session.host}:{session.port || 22}
|
|
||||||
</span>
|
|
||||||
{result?.durationMs !== undefined && (
|
|
||||||
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{(result?.output || result?.error) && (
|
<button type="button" disabled={running || filteredSessions.length === 0} onClick={toggleFiltered} className="text-[11px] text-accent-blue disabled:opacity-40">
|
||||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 text-xs leading-relaxed ${
|
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
|
||||||
result.error ? 'text-red-300' : 'text-content-secondary'
|
</button>
|
||||||
}`}>
|
|
||||||
{result.error || result.output}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<label className="relative block">
|
||||||
})}
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
|
||||||
|
<input
|
||||||
|
aria-label="筛选主机"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
disabled={running}
|
||||||
|
placeholder="名称、IP、分组或标签"
|
||||||
|
className="ui-input-sm w-full pl-8"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
|
{filteredSessions.map((session) => {
|
||||||
|
const selected = selectedIds.has(session.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={running}
|
||||||
|
onClick={() => toggleSession(session.id)}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'border-accent-blue/40 bg-accent-blue/10'
|
||||||
|
: 'border-transparent hover:border-dark-700 hover:bg-dark-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-950'}`}>
|
||||||
|
{selected && <Check className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs font-medium text-content-primary">{session.name}</span>
|
||||||
|
<span className="block truncate font-mono text-[10px] text-txt-faint">{session.username}@{session.host}:{session.port || 22}</span>
|
||||||
|
</span>
|
||||||
|
<span className="max-w-20 truncate rounded bg-dark-700/70 px-1.5 py-0.5 text-[9px] text-txt-muted">{session.group || '默认'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filteredSessions.length === 0 && <div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
|
||||||
|
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
|
||||||
|
<span className="text-xs font-medium text-content-secondary">执行结果</span>
|
||||||
|
<span className="text-[10px] text-txt-faint">输出仅保留在当前弹框内</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5">
|
||||||
|
{selectedSessions.map((session) => {
|
||||||
|
const result = results.find((item) => item.sessionId === session.id)
|
||||||
|
const status = result?.status ?? 'idle'
|
||||||
|
return (
|
||||||
|
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-950/70">
|
||||||
|
<div className="flex min-h-10 items-center gap-2 px-3 py-2">
|
||||||
|
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||||
|
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||||
|
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||||
|
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-content-primary">{session.name}</span>
|
||||||
|
<span className="truncate font-mono text-[10px] text-txt-muted">{session.host}</span>
|
||||||
|
{result?.durationMs !== undefined && <span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>}
|
||||||
|
</div>
|
||||||
|
{(result?.output || result?.error) && (
|
||||||
|
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words border-t border-dark-700/70 px-3 py-2 text-[11px] leading-relaxed ${result.error ? 'text-red-300' : 'text-content-secondary'}`}>
|
||||||
|
{result.error || result.output}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{selectedSessions.length === 0 && <div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">请从左侧选择目标主机</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -69,10 +69,11 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && initialTarget) {
|
if (!isOpen) return
|
||||||
setTarget(initialTarget)
|
setTarget(initialTarget)
|
||||||
setError(null)
|
setPassword('')
|
||||||
}
|
setError(null)
|
||||||
|
setIsConnecting(false)
|
||||||
}, [initialTarget, isOpen])
|
}, [initialTarget, isOpen])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, type ComponentType } from 'react'
|
||||||
|
import {
|
||||||
|
Clipboard,
|
||||||
|
ClipboardPaste,
|
||||||
|
Eraser,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
MousePointer2,
|
||||||
|
SeparatorHorizontal,
|
||||||
|
SplitSquareHorizontal,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface MenuAction {
|
||||||
|
label: string
|
||||||
|
icon: ComponentType<{ className?: string }>
|
||||||
|
onSelect: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
danger?: boolean
|
||||||
|
dividerBefore?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalContextMenuProps {
|
||||||
|
position: { x: number; y: number }
|
||||||
|
hasSelection: boolean
|
||||||
|
canSplit: boolean
|
||||||
|
canClosePane: boolean
|
||||||
|
canCloseTerminal: boolean
|
||||||
|
closeLabel: string
|
||||||
|
maximized: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCopy: () => void
|
||||||
|
onPaste: () => void
|
||||||
|
onSelectAll: () => void
|
||||||
|
onClear: () => void
|
||||||
|
onSplitRight: () => void
|
||||||
|
onSplitDown: () => void
|
||||||
|
onToggleMaximize: () => void
|
||||||
|
onClosePane: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalContextMenu({
|
||||||
|
position,
|
||||||
|
hasSelection,
|
||||||
|
canSplit,
|
||||||
|
canClosePane,
|
||||||
|
canCloseTerminal,
|
||||||
|
closeLabel,
|
||||||
|
maximized,
|
||||||
|
onClose,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
onSelectAll,
|
||||||
|
onClear,
|
||||||
|
onSplitRight,
|
||||||
|
onSplitDown,
|
||||||
|
onToggleMaximize,
|
||||||
|
onClosePane,
|
||||||
|
}: TerminalContextMenuProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const dismiss = () => onClose()
|
||||||
|
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
|
||||||
|
window.addEventListener('mousedown', dismiss)
|
||||||
|
window.addEventListener('blur', dismiss)
|
||||||
|
window.addEventListener('resize', dismiss)
|
||||||
|
window.addEventListener('keydown', escape)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', dismiss)
|
||||||
|
window.removeEventListener('blur', dismiss)
|
||||||
|
window.removeEventListener('resize', dismiss)
|
||||||
|
window.removeEventListener('keydown', escape)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const actions: MenuAction[] = [
|
||||||
|
{ label: '复制', icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
|
||||||
|
{ label: '粘贴', icon: ClipboardPaste, onSelect: onPaste },
|
||||||
|
{ label: '全选终端内容', icon: MousePointer2, onSelect: onSelectAll },
|
||||||
|
{ label: '清空显示', icon: Eraser, onSelect: onClear },
|
||||||
|
{ label: '向右分屏', icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
|
||||||
|
{ label: '向下分屏', icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
|
||||||
|
{
|
||||||
|
label: maximized ? '恢复分屏布局' : '最大化当前分屏',
|
||||||
|
icon: maximized ? Minimize2 : Maximize2,
|
||||||
|
onSelect: onToggleMaximize,
|
||||||
|
disabled: !canClosePane,
|
||||||
|
},
|
||||||
|
{ label: closeLabel, icon: X, onSelect: onClosePane, disabled: !canCloseTerminal, danger: true, dividerBefore: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const left = Math.min(position.x, Math.max(8, window.innerWidth - 220))
|
||||||
|
const top = Math.min(position.y, Math.max(8, window.innerHeight - 330))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label="终端操作"
|
||||||
|
style={{ left, top }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
className="fixed z-[140] w-52 overflow-hidden rounded-lg border border-dark-600/90 bg-dark-800 py-1.5 shadow-2xl shadow-black/45"
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<div key={action.label}>
|
||||||
|
{action.dividerBefore && <div className="my-1 border-t border-dark-700/70" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
action.onSelect()
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
|
||||||
|
action.danger
|
||||||
|
? 'text-red-300 hover:bg-red-500/10'
|
||||||
|
: 'text-content-secondary hover:bg-dark-700 hover:text-content-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<action.icon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span className="flex-1">{action.label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ import { themes } from '../../themes/terminal'
|
|||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { getConnectionStore } from '../../stores/connection'
|
import { getConnectionStore } from '../../stores/connection'
|
||||||
|
import {
|
||||||
|
getTerminalBroadcastTargets,
|
||||||
|
terminalBroadcastTargetCount,
|
||||||
|
useTerminalBroadcastStore,
|
||||||
|
} from '../../stores/terminalBroadcastStore'
|
||||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||||
import { shellPayloadByteLength } from '../../utils/shellData'
|
import { shellPayloadByteLength } from '../../utils/shellData'
|
||||||
@@ -35,9 +40,10 @@ import { createLogger } from '../../utils/logger'
|
|||||||
import type { TerminalViewProps } from './types'
|
import type { TerminalViewProps } from './types'
|
||||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||||
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
||||||
import { resolveTerminalInputTargets } from '../../utils/terminalPanes'
|
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
|
||||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||||
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
||||||
|
import TerminalContextMenu from './TerminalContextMenu'
|
||||||
|
|
||||||
const logger = createLogger('TerminalWorkspace')
|
const logger = createLogger('TerminalWorkspace')
|
||||||
const MAX_PANES = 4
|
const MAX_PANES = 4
|
||||||
@@ -55,6 +61,10 @@ interface PaneState {
|
|||||||
interface PaneRuntime {
|
interface PaneRuntime {
|
||||||
capture: () => TerminalAiContext
|
capture: () => TerminalAiContext
|
||||||
getSize: () => { cols: number; rows: number }
|
getSize: () => { cols: number; rows: number }
|
||||||
|
copySelection: () => void
|
||||||
|
pasteClipboard: () => void
|
||||||
|
selectAll: () => void
|
||||||
|
clear: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureTerminal(term: Terminal): TerminalAiContext {
|
function captureTerminal(term: Terminal): TerminalAiContext {
|
||||||
@@ -83,6 +93,7 @@ interface PaneTerminalProps {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
onFocus: () => void
|
onFocus: () => void
|
||||||
onInput: (paneId: string, data: string) => void
|
onInput: (paneId: string, data: string) => void
|
||||||
|
onContextMenu: (paneId: string, position: { x: number; y: number }, hasSelection: boolean) => void
|
||||||
onShellReady: (paneId: string, shellId: string) => void
|
onShellReady: (paneId: string, shellId: string) => void
|
||||||
registerShellHandler: (paneId: string, handler: (shellId: string, data: unknown) => void) => void
|
registerShellHandler: (paneId: string, handler: (shellId: string, data: unknown) => void) => void
|
||||||
unregisterShellHandler: (paneId: string) => void
|
unregisterShellHandler: (paneId: string) => void
|
||||||
@@ -101,6 +112,7 @@ function PaneTerminal({
|
|||||||
isActive,
|
isActive,
|
||||||
onFocus,
|
onFocus,
|
||||||
onInput,
|
onInput,
|
||||||
|
onContextMenu,
|
||||||
onShellReady,
|
onShellReady,
|
||||||
registerShellHandler,
|
registerShellHandler,
|
||||||
unregisterShellHandler,
|
unregisterShellHandler,
|
||||||
@@ -112,12 +124,14 @@ function PaneTerminal({
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||||
const shellIdRef = useRef(shellId)
|
const shellIdRef = useRef(shellId)
|
||||||
const onInputRef = useRef(onInput)
|
const onInputRef = useRef(onInput)
|
||||||
|
const onContextMenuRef = useRef(onContextMenu)
|
||||||
const creatingRef = useRef(false)
|
const creatingRef = useRef(false)
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
const settingsRef = useRef(settings)
|
const settingsRef = useRef(settings)
|
||||||
settingsRef.current = settings
|
settingsRef.current = settings
|
||||||
shellIdRef.current = shellId
|
shellIdRef.current = shellId
|
||||||
onInputRef.current = onInput
|
onInputRef.current = onInput
|
||||||
|
onContextMenuRef.current = onContextMenu
|
||||||
|
|
||||||
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
||||||
|
|
||||||
@@ -176,16 +190,19 @@ function PaneTerminal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copySelection = () => {
|
||||||
|
const selection = term.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
void writeClipboardText(selection).catch((error) => {
|
||||||
|
logger.warn('写入剪贴板失败', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
if (event.type !== 'keydown') return true
|
if (event.type !== 'keydown') return true
|
||||||
if (isTerminalCopyShortcut(event)) {
|
if (isTerminalCopyShortcut(event)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const selection = term.getSelection()
|
copySelection()
|
||||||
if (selection) {
|
|
||||||
void writeClipboardText(selection).catch((error) => {
|
|
||||||
logger.warn('写入剪贴板失败', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (isTerminalPasteShortcut(event)) {
|
if (isTerminalPasteShortcut(event)) {
|
||||||
@@ -198,14 +215,12 @@ function PaneTerminal({
|
|||||||
|
|
||||||
const handleContextMenu = (event: MouseEvent) => {
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const selection = term.getSelection()
|
event.stopPropagation()
|
||||||
if (selection) {
|
onContextMenuRef.current(
|
||||||
void writeClipboardText(selection).catch((error) => {
|
paneId,
|
||||||
logger.warn('写入剪贴板失败', error)
|
{ x: event.clientX, y: event.clientY },
|
||||||
})
|
term.hasSelection()
|
||||||
} else {
|
)
|
||||||
void pasteClipboard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
container.addEventListener('contextmenu', handleContextMenu)
|
container.addEventListener('contextmenu', handleContextMenu)
|
||||||
|
|
||||||
@@ -245,6 +260,10 @@ function PaneTerminal({
|
|||||||
registerRuntime(paneId, {
|
registerRuntime(paneId, {
|
||||||
capture: () => captureTerminal(term),
|
capture: () => captureTerminal(term),
|
||||||
getSize: () => ({ cols: term.cols, rows: term.rows }),
|
getSize: () => ({ cols: term.cols, rows: term.rows }),
|
||||||
|
copySelection,
|
||||||
|
pasteClipboard: () => { void pasteClipboard() },
|
||||||
|
selectAll: () => term.selectAll(),
|
||||||
|
clear: () => term.clear(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -324,7 +343,7 @@ function PaneTerminal({
|
|||||||
|
|
||||||
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
|
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
const { getTabById, updateTab, updateTabConnectionInstance } = useTabStore()
|
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs } = useTabStore()
|
||||||
const snapshotStore = useTerminalSnapshotStore()
|
const snapshotStore = useTerminalSnapshotStore()
|
||||||
const currentTab = getTabById(tabId)
|
const currentTab = getTabById(tabId)
|
||||||
const savedLayout = currentTab?.splitLayout
|
const savedLayout = currentTab?.splitLayout
|
||||||
@@ -342,8 +361,17 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'horizontal')
|
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'horizontal')
|
||||||
const [ratio, setRatio] = useState(savedLayout?.ratio ?? 0.5)
|
const [ratio, setRatio] = useState(savedLayout?.ratio ?? 0.5)
|
||||||
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
|
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
|
||||||
const [broadcastInput, setBroadcastInput] = useState(false)
|
const broadcastInput = useTerminalBroadcastStore((state) => state.enabled)
|
||||||
|
const setBroadcastInput = useTerminalBroadcastStore((state) => state.setEnabled)
|
||||||
|
const registerBroadcastWorkspace = useTerminalBroadcastStore((state) => state.registerWorkspace)
|
||||||
|
const unregisterBroadcastWorkspace = useTerminalBroadcastStore((state) => state.unregisterWorkspace)
|
||||||
|
const broadcastTargetCount = useTerminalBroadcastStore(terminalBroadcastTargetCount)
|
||||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
paneId: string
|
||||||
|
position: { x: number; y: number }
|
||||||
|
hasSelection: boolean
|
||||||
|
} | null>(null)
|
||||||
const draggingRef = useRef(false)
|
const draggingRef = useRef(false)
|
||||||
const layoutHydrated = useRef(false)
|
const layoutHydrated = useRef(false)
|
||||||
|
|
||||||
@@ -392,6 +420,30 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
}
|
}
|
||||||
}, [connectionState, shellIdRef])
|
}, [connectionState, shellIdRef])
|
||||||
|
|
||||||
|
// A single global broadcast scope covers every ready SSH shell in every
|
||||||
|
// currently open terminal tab. Local host shells are intentionally excluded.
|
||||||
|
useEffect(() => {
|
||||||
|
const connectionId = connectionIdRef.current
|
||||||
|
const targets = connectionState === 'connected' && connectionId && connectionId !== 'local' && !currentTab?.isLocal
|
||||||
|
? panes.flatMap((pane) => pane.shellId
|
||||||
|
? [{
|
||||||
|
tabId,
|
||||||
|
paneId: pane.id,
|
||||||
|
shellId: pane.shellId,
|
||||||
|
connectionId,
|
||||||
|
label: currentTab?.title || 'SSH 终端',
|
||||||
|
}]
|
||||||
|
: [])
|
||||||
|
: []
|
||||||
|
registerBroadcastWorkspace({ tabId, targets })
|
||||||
|
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
|
||||||
|
|
||||||
|
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (broadcastInput && broadcastTargetCount < 2) setBroadcastInput(false)
|
||||||
|
}, [broadcastInput, broadcastTargetCount, setBroadcastInput])
|
||||||
|
|
||||||
// Fan-out shell data to panes (SSH + local)
|
// Fan-out shell data to panes (SSH + local)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
|
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
|
||||||
@@ -571,17 +623,31 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
|
|
||||||
const handlePaneInput = useCallback(
|
const handlePaneInput = useCallback(
|
||||||
(sourcePaneId: string, data: string) => {
|
(sourcePaneId: string, data: string) => {
|
||||||
const targets = resolveTerminalInputTargets(panesRef.current, sourcePaneId, broadcastInput)
|
const shouldBroadcast = shouldBroadcastTerminalInput(
|
||||||
for (const targetShellId of targets) {
|
broadcastInput,
|
||||||
|
currentTab?.isLocal,
|
||||||
|
connectionIdRef.current
|
||||||
|
)
|
||||||
|
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets() : []
|
||||||
|
const targets = shouldBroadcast
|
||||||
|
? broadcastTargets.map((target) => target.shellId)
|
||||||
|
: resolveTerminalInputTargets(panesRef.current, sourcePaneId, false)
|
||||||
|
for (const targetShellId of new Set(targets)) {
|
||||||
void shellWrite(targetShellId, data)
|
void shellWrite(targetShellId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionId = connectionIdRef.current
|
if (shouldBroadcast) {
|
||||||
if (targets.length > 0 && connectionId && connectionId !== 'local') {
|
for (const connectionId of new Set(broadcastTargets.map((target) => target.connectionId))) {
|
||||||
getConnectionStore().updateActivity(connectionId)
|
getConnectionStore().updateActivity(connectionId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const connectionId = connectionIdRef.current
|
||||||
|
if (targets.length > 0 && connectionId && connectionId !== 'local') {
|
||||||
|
getConnectionStore().updateActivity(connectionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[broadcastInput, connectionIdRef]
|
[broadcastInput, connectionIdRef, currentTab?.isLocal]
|
||||||
)
|
)
|
||||||
|
|
||||||
const split = useCallback(
|
const split = useCallback(
|
||||||
@@ -614,27 +680,92 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
||||||
}, [isActive, split])
|
}, [isActive, split])
|
||||||
|
|
||||||
const closePane = (paneId: string) => {
|
const closePane = useCallback((paneId: string) => {
|
||||||
if (panes.length <= 1) return
|
if (panesRef.current.length <= 1) return
|
||||||
const pane = panes.find((item) => item.id === paneId)
|
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||||
if (currentTab?.isLocal && pane?.shellId) void shellKill(pane.shellId)
|
const connectionId = connectionIdRef.current
|
||||||
|
if (pane?.shellId) {
|
||||||
|
const closingShellId = pane.shellId
|
||||||
|
void shellKill(closingShellId)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.success) {
|
||||||
|
logger.warn('关闭分屏 Shell 失败', {
|
||||||
|
shellId: closingShellId,
|
||||||
|
error: result.error || 'unknown',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
|
||||||
|
if (connectionId && connectionId !== 'local') {
|
||||||
|
getConnectionStore().removeShell(connectionId, pane.shellId)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
|
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = prev.filter((p) => p.id !== paneId)
|
const next = prev.filter((p) => p.id !== paneId)
|
||||||
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
|
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
|
||||||
setFocusedPaneId(next[0].id)
|
setFocusedPaneId(next[0].id)
|
||||||
if (next[0].shellId) shellIdRef.current = next[0].shellId
|
if (next[0].shellId) {
|
||||||
|
shellIdRef.current = next[0].shellId
|
||||||
|
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}, [connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance])
|
||||||
|
|
||||||
|
const closePaneOrTab = useCallback((paneId: string) => {
|
||||||
|
if (panesRef.current.length > 1) {
|
||||||
|
closePane(paneId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tabs.length <= 1) return
|
||||||
|
|
||||||
|
const connectionId = connectionIdRef.current
|
||||||
|
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
|
||||||
|
void (async () => {
|
||||||
|
for (const paneShellId of paneShells) {
|
||||||
|
const result = await shellKill(paneShellId).catch((error) => ({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}))
|
||||||
|
if (!result.success) {
|
||||||
|
logger.warn('关闭标签 Shell 失败', {
|
||||||
|
shellId: paneShellId,
|
||||||
|
error: result.error || 'unknown',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
|
||||||
|
}
|
||||||
|
if (connectionId && connectionId !== 'local') {
|
||||||
|
await getConnectionStore().releaseConnection(connectionId, tabId)
|
||||||
|
}
|
||||||
|
closeTab(tabId)
|
||||||
|
})()
|
||||||
|
}, [closePane, closeTab, connectionIdRef, tabId, tabs.length])
|
||||||
|
|
||||||
|
const openTerminalContextMenu = useCallback((
|
||||||
|
paneId: string,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
hasSelection: boolean
|
||||||
|
) => {
|
||||||
|
setFocusedPaneId(paneId)
|
||||||
|
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||||
|
if (pane?.shellId && connectionIdRef.current) {
|
||||||
|
shellIdRef.current = pane.shellId
|
||||||
|
updateTabConnectionInstance(tabId, {
|
||||||
|
connectionId: connectionIdRef.current,
|
||||||
|
shellId: pane.shellId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setContextMenu({ paneId, position, hasSelection })
|
||||||
|
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (panes.length <= 1 && broadcastInput) setBroadcastInput(false)
|
|
||||||
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
||||||
setMaximizedPaneId(null)
|
setMaximizedPaneId(null)
|
||||||
}
|
}
|
||||||
}, [broadcastInput, maximizedPaneId, panes])
|
}, [maximizedPaneId, panes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = window.requestAnimationFrame(() => {
|
const frame = window.requestAnimationFrame(() => {
|
||||||
@@ -745,11 +876,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
|
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={broadcastInput ? '广播输入已开启:键盘输入将发送到所有分屏' : '广播输入到所有分屏'}
|
title={broadcastInput
|
||||||
|
? `广播已开启:键盘输入将发送到全部 ${broadcastTargetCount} 个已连接 SSH 终端`
|
||||||
|
: `广播输入到所有打开的 SSH 终端(${broadcastTargetCount})`}
|
||||||
aria-label="广播输入"
|
aria-label="广播输入"
|
||||||
aria-pressed={broadcastInput}
|
aria-pressed={broadcastInput}
|
||||||
disabled={!multi || connectionState !== 'connected'}
|
disabled={isLocal || connectionState !== 'connected' || broadcastTargetCount < 2}
|
||||||
onClick={() => setBroadcastInput((enabled) => !enabled)}
|
onClick={() => setBroadcastInput(!broadcastInput)}
|
||||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
|
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
|
||||||
broadcastInput
|
broadcastInput
|
||||||
? 'bg-accent-orange/15 text-accent-orange'
|
? 'bg-accent-orange/15 text-accent-orange'
|
||||||
@@ -771,7 +904,11 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
>
|
>
|
||||||
<Sparkles className="h-3.5 w-3.5" /> AI 助手
|
<Sparkles className="h-3.5 w-3.5" /> AI 助手
|
||||||
</button>
|
</button>
|
||||||
{broadcastInput && <div className="ui-chip border-accent-orange/30 text-accent-orange">广播中</div>}
|
{broadcastInput && (
|
||||||
|
<div className="ui-chip border-accent-orange/30 text-accent-orange">
|
||||||
|
广播中 · {broadcastTargetCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="ui-chip tabular-nums">
|
<div className="ui-chip tabular-nums">
|
||||||
<Square className="h-2.5 w-2.5" />
|
<Square className="h-2.5 w-2.5" />
|
||||||
{panes.length}/{MAX_PANES}
|
{panes.length}/{MAX_PANES}
|
||||||
@@ -801,6 +938,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
isFocused={focusedPaneId === pane.id}
|
isFocused={focusedPaneId === pane.id}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onInput={handlePaneInput}
|
onInput={handlePaneInput}
|
||||||
|
onContextMenu={openTerminalContextMenu}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocusedPaneId(pane.id)
|
setFocusedPaneId(pane.id)
|
||||||
if (pane.shellId && connectionIdRef.current) {
|
if (pane.shellId && connectionIdRef.current) {
|
||||||
@@ -865,6 +1003,26 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{contextMenu && (
|
||||||
|
<TerminalContextMenu
|
||||||
|
position={contextMenu.position}
|
||||||
|
hasSelection={contextMenu.hasSelection}
|
||||||
|
canSplit={connectionState === 'connected' && panes.length < MAX_PANES}
|
||||||
|
canClosePane={panes.length > 1}
|
||||||
|
canCloseTerminal={panes.length > 1 || tabs.length > 1}
|
||||||
|
closeLabel={panes.length > 1 ? '关闭当前分屏' : '关闭当前终端标签'}
|
||||||
|
maximized={maximizedPaneId === contextMenu.paneId}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
onCopy={() => paneRuntimesRef.current.get(contextMenu.paneId)?.copySelection()}
|
||||||
|
onPaste={() => paneRuntimesRef.current.get(contextMenu.paneId)?.pasteClipboard()}
|
||||||
|
onSelectAll={() => paneRuntimesRef.current.get(contextMenu.paneId)?.selectAll()}
|
||||||
|
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
|
||||||
|
onSplitRight={() => split('horizontal')}
|
||||||
|
onSplitDown={() => split('vertical')}
|
||||||
|
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
|
||||||
|
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -28,6 +28,7 @@ export interface DesktopApiContract {
|
|||||||
) => Promise<{ success: boolean; shellId?: string; error?: string }>
|
) => Promise<{ success: boolean; shellId?: string; error?: string }>
|
||||||
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
|
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
|
||||||
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
|
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
|
||||||
|
sshCloseShell: (shellId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
// Local terminal (host shell)
|
// Local terminal (host shell)
|
||||||
localListShells: () => Promise<{
|
localListShells: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const desktopApi: DesktopApi = {
|
|||||||
}),
|
}),
|
||||||
sshShellWrite: (shellId, data) => call('ssh_shell_write', { shellId, data: bytes(data) }),
|
sshShellWrite: (shellId, data) => call('ssh_shell_write', { shellId, data: bytes(data) }),
|
||||||
sshShellResize: (shellId, cols, rows) => call('ssh_shell_resize', { shellId, cols, rows }),
|
sshShellResize: (shellId, cols, rows) => call('ssh_shell_resize', { shellId, cols, rows }),
|
||||||
|
sshCloseShell: (shellId) => call('ssh_close_shell', { shellId }),
|
||||||
|
|
||||||
localListShells: async () => ({ success: true, shells: await call('local_list_shells') }),
|
localListShells: async () => ({ success: true, shells: await call('local_list_shells') }),
|
||||||
localCreateShell: async (options, shellId) => {
|
localCreateShell: async (options, shellId) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
||||||
type KeywordHighlightRule,
|
type KeywordHighlightRule,
|
||||||
} from '../utils/keywordHighlight'
|
} from '../utils/keywordHighlight'
|
||||||
|
import type { AgentSafetyMode } from '../utils/agentCommandRisk'
|
||||||
|
|
||||||
const logger = createLogger('SettingsStore')
|
const logger = createLogger('SettingsStore')
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export interface AppSettings {
|
|||||||
scrollback: number
|
scrollback: number
|
||||||
keywordHighlightEnabled: boolean
|
keywordHighlightEnabled: boolean
|
||||||
keywordHighlightRules: KeywordHighlightRule[]
|
keywordHighlightRules: KeywordHighlightRule[]
|
||||||
|
agentSafetyMode: AgentSafetyMode
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
@@ -56,6 +58,7 @@ const defaultSettings: AppSettings = {
|
|||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
keywordHighlightEnabled: true,
|
keywordHighlightEnabled: true,
|
||||||
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
||||||
|
agentSafetyMode: 'balanced',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAppSettings(value: unknown): AppSettings {
|
export function normalizeAppSettings(value: unknown): AppSettings {
|
||||||
@@ -69,6 +72,9 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
|||||||
const fallback = defaultSettings[key]
|
const fallback = defaultSettings[key]
|
||||||
normalized[key] = typeof candidate === typeof fallback ? candidate : fallback
|
normalized[key] = typeof candidate === typeof fallback ? candidate : fallback
|
||||||
}
|
}
|
||||||
|
if (!['strict', 'balanced', 'permissive'].includes(String(source.agentSafetyMode))) {
|
||||||
|
normalized.agentSafetyMode = defaultSettings.agentSafetyMode
|
||||||
|
}
|
||||||
|
|
||||||
return normalized as unknown as AppSettings
|
return normalized as unknown as AppSettings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface TerminalBroadcastTarget {
|
||||||
|
tabId: string
|
||||||
|
paneId: string
|
||||||
|
shellId: string
|
||||||
|
connectionId: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalBroadcastWorkspace {
|
||||||
|
tabId: string
|
||||||
|
targets: TerminalBroadcastTarget[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalBroadcastState {
|
||||||
|
enabled: boolean
|
||||||
|
workspaces: Record<string, TerminalBroadcastWorkspace>
|
||||||
|
setEnabled: (enabled: boolean) => void
|
||||||
|
registerWorkspace: (workspace: TerminalBroadcastWorkspace) => void
|
||||||
|
unregisterWorkspace: (tabId: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTerminalBroadcastStore = create<TerminalBroadcastState>((set) => ({
|
||||||
|
enabled: false,
|
||||||
|
workspaces: {},
|
||||||
|
setEnabled: (enabled) => set({ enabled }),
|
||||||
|
registerWorkspace: (workspace) =>
|
||||||
|
set((state) => ({
|
||||||
|
workspaces: { ...state.workspaces, [workspace.tabId]: workspace },
|
||||||
|
})),
|
||||||
|
unregisterWorkspace: (tabId) =>
|
||||||
|
set((state) => {
|
||||||
|
const workspaces = { ...state.workspaces }
|
||||||
|
delete workspaces[tabId]
|
||||||
|
return { workspaces }
|
||||||
|
}),
|
||||||
|
reset: () => set({ enabled: false, workspaces: {} }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Every ready remote shell across all currently open terminal tabs and panes. */
|
||||||
|
export function getTerminalBroadcastTargets(): TerminalBroadcastTarget[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const targets: TerminalBroadcastTarget[] = []
|
||||||
|
for (const workspace of Object.values(useTerminalBroadcastStore.getState().workspaces)) {
|
||||||
|
for (const target of workspace.targets) {
|
||||||
|
if (seen.has(target.shellId)) continue
|
||||||
|
seen.add(target.shellId)
|
||||||
|
targets.push(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalBroadcastTargetCount(state: TerminalBroadcastState): number {
|
||||||
|
const shellIds = new Set<string>()
|
||||||
|
for (const workspace of Object.values(state.workspaces)) {
|
||||||
|
for (const target of workspace.targets) shellIds.add(target.shellId)
|
||||||
|
}
|
||||||
|
return shellIds.size
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export type AgentCommandRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical'
|
export type AgentCommandRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical'
|
||||||
|
export type AgentSafetyMode = 'strict' | 'balanced' | 'permissive'
|
||||||
|
|
||||||
const RISK_WEIGHT: Record<AgentCommandRisk, number> = {
|
const RISK_WEIGHT: Record<AgentCommandRisk, number> = {
|
||||||
safe: 0,
|
safe: 0,
|
||||||
@@ -97,6 +98,37 @@ export const AGENT_RISK_LABEL: Record<AgentCommandRisk, string> = {
|
|||||||
critical: '禁止执行',
|
critical: '禁止执行',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AGENT_SAFETY_MODES: Record<
|
||||||
|
AgentSafetyMode,
|
||||||
|
{ label: string; short: string; description: string }
|
||||||
|
> = {
|
||||||
|
strict: {
|
||||||
|
label: '严格',
|
||||||
|
short: '中风险起确认',
|
||||||
|
description: '文件修改、权限调整及更高风险命令需要确认,适合生产或陌生主机。',
|
||||||
|
},
|
||||||
|
balanced: {
|
||||||
|
label: '均衡',
|
||||||
|
short: '仅高风险确认',
|
||||||
|
description: '查看和普通修改自动执行;删除、重启、安装等高风险操作需要确认。',
|
||||||
|
},
|
||||||
|
permissive: {
|
||||||
|
label: '宽松',
|
||||||
|
short: '仅硬红线拦截',
|
||||||
|
description: '除 critical 破坏性命令外自动执行,仅适合隔离测试环境。',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentCommandNeedsConfirmation(
|
||||||
|
risk: AgentCommandRisk,
|
||||||
|
mode: AgentSafetyMode
|
||||||
|
): boolean {
|
||||||
|
if (risk === 'critical') return false
|
||||||
|
if (mode === 'strict') return RISK_WEIGHT[risk] >= RISK_WEIGHT.medium
|
||||||
|
if (mode === 'balanced') return RISK_WEIGHT[risk] >= RISK_WEIGHT.high
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function maxRisk(left: AgentCommandRisk, right: AgentCommandRisk): AgentCommandRisk {
|
function maxRisk(left: AgentCommandRisk, right: AgentCommandRisk): AgentCommandRisk {
|
||||||
return RISK_WEIGHT[left] >= RISK_WEIGHT[right] ? left : right
|
return RISK_WEIGHT[left] >= RISK_WEIGHT[right] ? left : right
|
||||||
}
|
}
|
||||||
@@ -162,8 +194,8 @@ function assessSegment(value: string): AgentCommandRisk {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Five-level best-effort risk assessment for AI-proposed one-line commands.
|
* Five-level best-effort risk assessment for AI-proposed one-line commands.
|
||||||
* Every non-critical command still requires explicit user confirmation; this
|
* Confirmation is decided separately by the selected Agent safety mode.
|
||||||
* classifier controls hard denial and communicates risk, never authorization.
|
* Critical commands are always denied and can never be approved by a mode.
|
||||||
*/
|
*/
|
||||||
export function assessAgentCommandRisk(command: string): AgentCommandRisk {
|
export function assessAgentCommandRisk(command: string): AgentCommandRisk {
|
||||||
const normalized = command.replace(/\s+/g, ' ').trim()
|
const normalized = command.replace(/\s+/g, ' ').trim()
|
||||||
|
|||||||
@@ -117,7 +117,12 @@ export async function shellKill(
|
|||||||
shellId: string
|
shellId: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
if (!isLocalShellId(shellId)) {
|
if (!isLocalShellId(shellId)) {
|
||||||
return { success: false, error: 'only local shell termination is supported' }
|
return (
|
||||||
|
(await window.opsxApi?.sshCloseShell?.(shellId)) ?? {
|
||||||
|
success: false,
|
||||||
|
error: 'ssh close shell API unavailable',
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (isTauriRuntime()) {
|
if (isTauriRuntime()) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ export interface TerminalPaneTarget {
|
|||||||
shellId: string | null
|
shellId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global broadcast is a remote-SSH feature. A local terminal must never fan
|
||||||
|
* its input out to remote shells even while another workspace enabled it.
|
||||||
|
*/
|
||||||
|
export function shouldBroadcastTerminalInput(
|
||||||
|
enabled: boolean,
|
||||||
|
isLocal: boolean | undefined,
|
||||||
|
connectionId: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return enabled && !isLocal && !!connectionId && connectionId !== 'local'
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve ready shells for an input event, preserving pane order. */
|
/** Resolve ready shells for an input event, preserving pane order. */
|
||||||
export function resolveTerminalInputTargets(
|
export function resolveTerminalInputTargets(
|
||||||
panes: TerminalPaneTarget[],
|
panes: TerminalPaneTarget[],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AiTerminalPanel from '../../src/components/ai/AiTerminalPanel'
|
import AiTerminalPanel from '../../src/components/ai/AiTerminalPanel'
|
||||||
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
||||||
|
import { useSettingsStore } from '../../src/stores/settingsStore'
|
||||||
import { mockDesktopApi } from '../setup'
|
import { mockDesktopApi } from '../setup'
|
||||||
|
|
||||||
describe('terminal Agent CLI panel', () => {
|
describe('terminal Agent CLI panel', () => {
|
||||||
@@ -27,6 +28,9 @@ describe('terminal Agent CLI panel', () => {
|
|||||||
catalogProviders: [],
|
catalogProviders: [],
|
||||||
catalogUpdatedAt: null,
|
catalogUpdatedAt: null,
|
||||||
})
|
})
|
||||||
|
useSettingsStore.setState((state) => ({
|
||||||
|
settings: { ...state.settings, agentSafetyMode: 'balanced' },
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses one task entry, Enter to run, Shift+Enter for a newline, and selected context only', async () => {
|
it('uses one task entry, Enter to run, Shift+Enter for a newline, and selected context only', async () => {
|
||||||
@@ -61,7 +65,7 @@ describe('terminal Agent CLI panel', () => {
|
|||||||
expect(await screen.findByText('这是远端 Bash TMOUT 策略导致的自动退出。')).toBeVisible()
|
expect(await screen.findByText('这是远端 Bash TMOUT 策略导致的自动退出。')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('runs a bounded observe loop on the active SSH connection with confirmation before every command', async () => {
|
it('runs safe diagnostic commands automatically in the default balanced mode', async () => {
|
||||||
vi.mocked(mockDesktopApi.aiComplete)
|
vi.mocked(mockDesktopApi.aiComplete)
|
||||||
.mockResolvedValueOnce({ success: true, text: '```bash\nuptime\n```', model: 'test', redactedCount: 0 })
|
.mockResolvedValueOnce({ success: true, text: '```bash\nuptime\n```', model: 'test', redactedCount: 0 })
|
||||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 负载正常,任务完成。', model: 'test', redactedCount: 0 })
|
.mockResolvedValueOnce({ success: true, text: 'FINAL: 负载正常,任务完成。', model: 'test', redactedCount: 0 })
|
||||||
@@ -85,19 +89,67 @@ describe('terminal Agent CLI panel', () => {
|
|||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog')
|
|
||||||
expect(within(dialog).getByText(/root@opsbase.cn/)).toBeVisible()
|
|
||||||
expect(within(dialog).getByText(/uptime/)).toBeVisible()
|
|
||||||
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: '执行' }))
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'uptime'))
|
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'uptime'))
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
expect(await screen.findByText('load average: 0.01')).toBeVisible()
|
expect(await screen.findByText('load average: 0.01')).toBeVisible()
|
||||||
expect(await screen.findByText('负载正常,任务完成。')).toBeVisible()
|
expect(await screen.findByText('负载正常,任务完成。')).toBeVisible()
|
||||||
expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(2)
|
expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(2)
|
||||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[1][0].context).toContain('[exit_code=0]')
|
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[1][0].context).toContain('[exit_code=0]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('asks for confirmation for high-risk commands in the default balanced mode', async () => {
|
||||||
|
vi.mocked(mockDesktopApi.aiComplete)
|
||||||
|
.mockResolvedValueOnce({ success: true, text: '```bash\nsystemctl restart nginx\n```', model: 'test', redactedCount: 0 })
|
||||||
|
.mockResolvedValueOnce({ success: true, text: 'FINAL: nginx 已重启。', model: 'test', redactedCount: 0 })
|
||||||
|
vi.mocked(mockDesktopApi.sshExec).mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AiTerminalPanel
|
||||||
|
capture={() => ({ selection: '', viewport: '' })}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
connectionId="ssh-active-1"
|
||||||
|
targetLabel="root@opsbase.cn"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||||
|
target: { value: '重启 nginx' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole('dialog')
|
||||||
|
expect(within(dialog).getByText(/均衡/)).toBeVisible()
|
||||||
|
expect(within(dialog).getByText(/systemctl restart nginx/)).toBeVisible()
|
||||||
|
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '确认风险并执行' }))
|
||||||
|
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets strict mode require confirmation from medium risk upward', async () => {
|
||||||
|
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
text: '```bash\ntouch /tmp/opsxshell-agent-test\n```',
|
||||||
|
model: 'test',
|
||||||
|
redactedCount: 0,
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<AiTerminalPanel
|
||||||
|
capture={() => ({ selection: '', viewport: '' })}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
connectionId="ssh-active-1"
|
||||||
|
targetLabel="root@opsbase.cn"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '严格' }))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||||
|
target: { value: '创建临时文件' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole('dialog')
|
||||||
|
expect(within(dialog).getByText(/touch \/tmp\/opsxshell-agent-test/)).toBeVisible()
|
||||||
|
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('hard-denies critical destructive commands without offering confirmation', async () => {
|
it('hard-denies critical destructive commands without offering confirmation', async () => {
|
||||||
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -153,7 +205,7 @@ describe('terminal Agent CLI panel', () => {
|
|||||||
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('asks again before a second command in the same task', async () => {
|
it('automatically executes multiple read-only commands in the same balanced task', async () => {
|
||||||
vi.mocked(mockDesktopApi.aiComplete)
|
vi.mocked(mockDesktopApi.aiComplete)
|
||||||
.mockResolvedValueOnce({ success: true, text: '```bash\ndate\n```', model: 'test', redactedCount: 0 })
|
.mockResolvedValueOnce({ success: true, text: '```bash\ndate\n```', model: 'test', redactedCount: 0 })
|
||||||
.mockResolvedValueOnce({ success: true, text: '```bash\nuname -a\n```', model: 'test', redactedCount: 0 })
|
.mockResolvedValueOnce({ success: true, text: '```bash\nuname -a\n```', model: 'test', redactedCount: 0 })
|
||||||
@@ -175,16 +227,8 @@ describe('terminal Agent CLI panel', () => {
|
|||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||||
|
|
||||||
let dialog = await screen.findByRole('dialog')
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: '执行' }))
|
|
||||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1))
|
|
||||||
|
|
||||||
dialog = await screen.findByRole('dialog')
|
|
||||||
expect(within(dialog).getByText(/uname -a/)).toBeVisible()
|
|
||||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1)
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: '执行' }))
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
expect(await screen.findByText('两项检查已完成。')).toBeVisible()
|
expect(await screen.findByText('两项检查已完成。')).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -33,7 +33,7 @@ describe('BatchCommandDialog', () => {
|
|||||||
render(<BatchCommandDialog isOpen sessions={sessions} onClose={vi.fn()} />)
|
render(<BatchCommandDialog isOpen sessions={sessions} onClose={vi.fn()} />)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('远程命令'), 'date')
|
await user.type(screen.getByLabelText('远程命令'), 'date')
|
||||||
await user.click(screen.getByRole('button', { name: '执行命令' }))
|
await user.click(screen.getByRole('button', { name: '执行到 2 台' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
|
||||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('connection-10.0.0.1', 'date')
|
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('connection-10.0.0.1', 'date')
|
||||||
@@ -43,4 +43,41 @@ describe('BatchCommandDialog', () => {
|
|||||||
expect(screen.getByText('connection-10.0.0.1: date')).toBeVisible()
|
expect(screen.getByText('connection-10.0.0.1: date')).toBeVisible()
|
||||||
expect(screen.getByText('connection-10.0.0.2: date')).toBeVisible()
|
expect(screen.getByText('connection-10.0.0.2: date')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('starts with the sidebar selection and lets the user adjust targets in the dialog', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<BatchCommandDialog
|
||||||
|
isOpen
|
||||||
|
sessions={sessions}
|
||||||
|
initialSelectedIds={['host-a']}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Host A/ })).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Host B/ })).toHaveAttribute('aria-checked', 'false')
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: /Host B/ }))
|
||||||
|
expect(screen.getByRole('button', { name: '执行到 2 台' })).toBeVisible()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('筛选主机'), '10.0.0.1')
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Host A/ })).toBeVisible()
|
||||||
|
expect(screen.queryByRole('checkbox', { name: /Host B/ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the command when reopened so sensitive input is not retained', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { rerender } = render(
|
||||||
|
<BatchCommandDialog isOpen sessions={sessions} onClose={vi.fn()} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const command = screen.getByLabelText('远程命令')
|
||||||
|
await user.type(command, 'cat /etc/shadow')
|
||||||
|
expect(command).toHaveValue('cat /etc/shadow')
|
||||||
|
|
||||||
|
rerender(<BatchCommandDialog isOpen={false} sessions={sessions} onClose={vi.fn()} />)
|
||||||
|
rerender(<BatchCommandDialog isOpen sessions={sessions} onClose={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('远程命令')).toHaveValue('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -205,4 +205,18 @@ describe('Session workflow acceptance', () => {
|
|||||||
expect(sessionTarget).toHaveValue('admin@example.com:2200')
|
expect(sessionTarget).toHaveValue('admin@example.com:2200')
|
||||||
expect(screen.getByLabelText('端口')).toHaveValue(2200)
|
expect(screen.getByLabelText('端口')).toHaveValue(2200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears quick-connect credentials whenever the dialog is reopened', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { rerender } = render(<QuickConnectDialog isOpen onClose={vi.fn()} />)
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText('root@192.168.1.10:22'), 'root@example.com')
|
||||||
|
await user.type(screen.getByPlaceholderText('••••••••'), 'temporary-secret')
|
||||||
|
|
||||||
|
rerender(<QuickConnectDialog isOpen={false} onClose={vi.fn()} />)
|
||||||
|
rerender(<QuickConnectDialog isOpen onClose={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('root@192.168.1.10:22')).toHaveValue('')
|
||||||
|
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import TerminalContextMenu from '../../src/components/terminal/TerminalContextMenu'
|
||||||
|
|
||||||
|
function renderMenu(overrides: Partial<ComponentProps<typeof TerminalContextMenu>> = {}) {
|
||||||
|
const props: ComponentProps<typeof TerminalContextMenu> = {
|
||||||
|
position: { x: 20, y: 20 },
|
||||||
|
hasSelection: false,
|
||||||
|
canSplit: true,
|
||||||
|
canClosePane: true,
|
||||||
|
canCloseTerminal: true,
|
||||||
|
closeLabel: '关闭当前分屏',
|
||||||
|
maximized: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onCopy: vi.fn(),
|
||||||
|
onPaste: vi.fn(),
|
||||||
|
onSelectAll: vi.fn(),
|
||||||
|
onClear: vi.fn(),
|
||||||
|
onSplitRight: vi.fn(),
|
||||||
|
onSplitDown: vi.fn(),
|
||||||
|
onToggleMaximize: vi.fn(),
|
||||||
|
onClosePane: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
render(<TerminalContextMenu {...props} />)
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TerminalContextMenu', () => {
|
||||||
|
it('provides standard clipboard and pane actions without browser debug entries', () => {
|
||||||
|
const props = renderMenu()
|
||||||
|
expect(screen.getByRole('menuitem', { name: '复制' })).toBeDisabled()
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }))
|
||||||
|
expect(props.onPaste).toHaveBeenCalled()
|
||||||
|
expect(screen.queryByText('Reload')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Inspect Element')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes right/down split and the context-aware close action', () => {
|
||||||
|
const props = renderMenu({ hasSelection: true, closeLabel: '关闭当前终端标签' })
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: '向右分屏' }))
|
||||||
|
expect(props.onSplitRight).toHaveBeenCalled()
|
||||||
|
|
||||||
|
renderMenu({ closeLabel: '关闭当前终端标签' })
|
||||||
|
expect(screen.getAllByRole('menuitem', { name: '关闭当前终端标签' })[0]).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,6 +26,7 @@ export const mockDesktopApi: DesktopApiContract = {
|
|||||||
sshCreateShell: vi.fn().mockResolvedValue({ success: true, shellId: 'test-shell-1' }),
|
sshCreateShell: vi.fn().mockResolvedValue({ success: true, shellId: 'test-shell-1' }),
|
||||||
sshShellWrite: vi.fn().mockResolvedValue({ success: true }),
|
sshShellWrite: vi.fn().mockResolvedValue({ success: true }),
|
||||||
sshShellResize: vi.fn().mockResolvedValue({ success: true }),
|
sshShellResize: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
sshCloseShell: vi.fn().mockResolvedValue({ success: true }),
|
||||||
sshForwardLocal: vi.fn().mockResolvedValue({ success: true }),
|
sshForwardLocal: vi.fn().mockResolvedValue({ success: true }),
|
||||||
sshForwardRemote: vi.fn().mockResolvedValue({ success: true }),
|
sshForwardRemote: vi.fn().mockResolvedValue({ success: true }),
|
||||||
sshForwardDynamic: vi.fn().mockResolvedValue({ success: true }),
|
sshForwardDynamic: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
getTerminalBroadcastTargets,
|
||||||
|
terminalBroadcastTargetCount,
|
||||||
|
useTerminalBroadcastStore,
|
||||||
|
} from '../../src/stores/terminalBroadcastStore'
|
||||||
|
|
||||||
|
describe('terminal broadcast store', () => {
|
||||||
|
beforeEach(() => useTerminalBroadcastStore.getState().reset())
|
||||||
|
|
||||||
|
it('collects ready shells from every open terminal workspace and removes closed tabs', () => {
|
||||||
|
const store = useTerminalBroadcastStore.getState()
|
||||||
|
store.registerWorkspace({
|
||||||
|
tabId: 'tab-a',
|
||||||
|
targets: [
|
||||||
|
{ tabId: 'tab-a', paneId: 'left', shellId: 'shell-a', connectionId: 'conn-a', label: 'Host A' },
|
||||||
|
{ tabId: 'tab-a', paneId: 'right', shellId: 'shell-b', connectionId: 'conn-a', label: 'Host A' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
store.registerWorkspace({
|
||||||
|
tabId: 'tab-b',
|
||||||
|
targets: [
|
||||||
|
{ tabId: 'tab-b', paneId: 'main', shellId: 'shell-c', connectionId: 'conn-b', label: 'Host B' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getTerminalBroadcastTargets().map((target) => target.shellId)).toEqual([
|
||||||
|
'shell-a',
|
||||||
|
'shell-b',
|
||||||
|
'shell-c',
|
||||||
|
])
|
||||||
|
expect(terminalBroadcastTargetCount(useTerminalBroadcastStore.getState())).toBe(3)
|
||||||
|
|
||||||
|
useTerminalBroadcastStore.getState().unregisterWorkspace('tab-a')
|
||||||
|
expect(getTerminalBroadcastTargets().map((target) => target.shellId)).toEqual(['shell-c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates a shell defensively', () => {
|
||||||
|
useTerminalBroadcastStore.getState().registerWorkspace({
|
||||||
|
tabId: 'tab-a',
|
||||||
|
targets: [
|
||||||
|
{ tabId: 'tab-a', paneId: 'a', shellId: 'shared', connectionId: 'conn-a', label: 'A' },
|
||||||
|
{ tabId: 'tab-a', paneId: 'b', shellId: 'shared', connectionId: 'conn-a', label: 'A' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(getTerminalBroadcastTargets()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { analyzeAgentOutput } from '../../src/utils/agentOutput'
|
import { analyzeAgentOutput } from '../../src/utils/agentOutput'
|
||||||
import { assessAgentCommandRisk } from '../../src/utils/agentCommandRisk'
|
import {
|
||||||
|
agentCommandNeedsConfirmation,
|
||||||
|
assessAgentCommandRisk,
|
||||||
|
} from '../../src/utils/agentCommandRisk'
|
||||||
|
|
||||||
describe('Agent command guardrail', () => {
|
describe('Agent command guardrail', () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -17,6 +20,15 @@ describe('Agent command guardrail', () => {
|
|||||||
])('classifies %s as %s', (command, expected) => {
|
])('classifies %s as %s', (command, expected) => {
|
||||||
expect(assessAgentCommandRisk(command)).toBe(expected)
|
expect(assessAgentCommandRisk(command)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses strict, balanced and permissive confirmation thresholds', () => {
|
||||||
|
expect(agentCommandNeedsConfirmation('safe', 'strict')).toBe(false)
|
||||||
|
expect(agentCommandNeedsConfirmation('medium', 'strict')).toBe(true)
|
||||||
|
expect(agentCommandNeedsConfirmation('medium', 'balanced')).toBe(false)
|
||||||
|
expect(agentCommandNeedsConfirmation('high', 'balanced')).toBe(true)
|
||||||
|
expect(agentCommandNeedsConfirmation('high', 'permissive')).toBe(false)
|
||||||
|
expect(agentCommandNeedsConfirmation('critical', 'permissive')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Agent analyze_output tool', () => {
|
describe('Agent analyze_output tool', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { resolveTerminalInputTargets } from '../../src/utils/terminalPanes'
|
import {
|
||||||
|
resolveTerminalInputTargets,
|
||||||
|
shouldBroadcastTerminalInput,
|
||||||
|
} from '../../src/utils/terminalPanes'
|
||||||
|
|
||||||
const panes = [
|
const panes = [
|
||||||
{ id: 'left', shellId: 'shell-1' },
|
{ id: 'left', shellId: 'shell-1' },
|
||||||
@@ -23,4 +26,11 @@ describe('terminal pane input routing', () => {
|
|||||||
expect(resolveTerminalInputTargets(panes, 'starting', false)).toEqual([])
|
expect(resolveTerminalInputTargets(panes, 'starting', false)).toEqual([])
|
||||||
expect(resolveTerminalInputTargets(panes, 'missing', false)).toEqual([])
|
expect(resolveTerminalInputTargets(panes, 'missing', false)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('never broadcasts input originating from a local terminal', () => {
|
||||||
|
expect(shouldBroadcastTerminalInput(true, true, 'local')).toBe(false)
|
||||||
|
expect(shouldBroadcastTerminalInput(true, true, 'ssh-connection')).toBe(false)
|
||||||
|
expect(shouldBroadcastTerminalInput(true, false, 'ssh-connection')).toBe(true)
|
||||||
|
expect(shouldBroadcastTerminalInput(true, false, null)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user