Compare commits

3 Commits

Author SHA1 Message Date
Your Name 88cc5b233a docs: 添加 v0.2.0 版本的 README 文档 2026-06-16 15:27:24 +08:00
Your Name a141fe7409 feat: v0.2.0 - 优化布局结构,确保AI输出不影响侧边栏可见性 2026-06-16 15:22:37 +08:00
Your Name 47540796f5 fix(pack): 优化打包脚本的日志输出格式
- 在打包开始时添加日志提示 "Packaging..."
- 删除代码签名相关环境变量的代码前保留清晰分隔
- 将成功日志改为换行输出,提升可读性
- 保持失败日志和退出流程不变
2026-06-12 18:34:06 +08:00
22 changed files with 2205 additions and 134 deletions
+175
View File
@@ -0,0 +1,175 @@
# 智能 SSH 终端 - SSH AI Agent
集成 AI 助手的远程运维桌面客户端,支持 SSH 连接、终端管理和自然语言辅助。
## 功能特性
### 核心功能
- **SSH 连接管理**:创建、编辑和管理 SSH 连接配置
- **分组管理**:将连接按分组组织,便于管理
- **终端会话**:全功能终端模拟器,支持多标签页
- **AI 助手集成**:内置 AI 聊天,支持自然语言描述运维任务
### v0.2.0 更新
- ✅ 优化布局结构:确保 AI 输出内容不影响侧边栏可见性
- ✅ AI 助手面板独立化:支持自由开关控制
- ✅ 新增面板切换按钮:在侧边栏底部提供快速开关入口
- ✅ 所有区域滚动支持:确保内容过多时可正常滚动查看
- ✅ 侧边栏永不消失:设置 flex-shrink-0 确保导航区域稳定
## 技术栈
- **Electron**:跨平台桌面应用框架
- **React**UI 组件库
- **TypeScript**:类型安全的 JavaScript 超集
- **Tailwind CSS**:实用优先的 CSS 框架
- **XTerm.js**Web 终端模拟器
- **Zustand**:轻量级状态管理
## 安装与使用
### 开发模式
```bash
# 安装依赖
npm install
# 启动开发服务器(包含热重载)
npm run dev:watch
# 或直接编译并运行
npm run dev
```
### 构建打包
```bash
# 编译前端和后端
npm run build
# 生成绿色免安装版本(目录版)
npm run pack
# 生成完整安装包(NSIS
npm run dist
```
打包后的文件将存放在 `release/` 目录下:
- 绿色版:`release/win-unpacked/智能SSH终端.exe`
- 安装包:`release/智能SSH终端-x.x.x-x64.exe`
## 项目结构
```
.
├── src/
│ ├── main/ # Electron 主进程
│ │ ├── index.ts # 主入口
│ │ ├── preload.ts # 预加载脚本
│ │ ├── handlers/ # IPC 处理程序
│ │ └── services/ # 业务服务
│ ├── renderer/ # 渲染进程
│ │ ├── src/ # React 源代码
│ │ ├── index.html # 入口 HTML
│ │ └── ...
│ └── shared/ # 共享类型定义
├── resources/ # 资源文件
├── dist/ # 编译输出
├── release/ # 打包输出
└── ...
```
## v0.2.0 布局说明
新的 3 列布局确保了最佳的用户体验:
```
┌─────────────────────────────────────────────────────────────┐
│ 标题栏 │
├─────────────────────────────────────────────────────────────┤
│ │ │ │ │
│ │ │ ┌──────────────────────┐ │ │
│ │ │ │ 会话/分组/模型面板 │ │ │
│ │ │ └──────────────────────┘ │ │
│ │ │ │ │
│ │ │ ┌──────────────────────┐ │ │
│ │ │ │ 终端区域 │ │ │
│ │ │ └──────────────────────┘ │ │
│ │ │ │ │
│ │ └──────────────────────────────────────────────────┘ │
│ │ │
│ │ 主工作区(管理面板 + 终端) │
│ │ │
│ │ ┌───────────────────────────────────────────────────┐ │
│ │ │ AI 助手面板(可展开/收起) │ │
│ │ │ - 模型选择 │ │
│ │ │ - 聊天界面 │ │
│ │ │ - 工具调用卡片 │ │
│ │ └───────────────────────────────────────────────────┘ │
│ └──────────────────────────────────────────────────────────┘
│ │
└────────────────────────────────────────────────────────────┘
```
### 关键改进
1. **左侧导航**:固定宽度,`flex-shrink-0` 确保永不被压缩
2. **AI 面板开关**:在侧边栏底部增加独立的 AI 助手切换按钮
3. **面板可收起**:AI 助手面板支持完全收起,为主工作区提供更大空间
## 依赖列表
### 核心依赖
- `@xterm/xterm``@xterm/addon-fit`:终端模拟器
- `react``react-dom`UI 框架
- `electron-store`:本地数据存储
- `openai`AI 模型集成
- `lucide-react`:图标库
- `zustand`:状态管理
### 开发工具
- `typescript`:类型检查
- `vite`:前端构建工具
- `electron-builder`:打包工具
- `tailwindcss`CSS 框架
## 开发者指南
### 修改配置
- TypeScript 配置:`tsconfig.main.json`(主进程),`tsconfig.renderer.json`(渲染进程)
- Vite 配置:`vite.config.ts`
- Tailwind 配置:`src/renderer/tailwind.config.js`
- Electron Builder 配置:`package.json``build` 字段
### 常见问题
#### 无法启动开发模式
确保已完整安装依赖,并且 Node.js 版本大于等于 16。
#### 打包时下载 Electron 失败
可以配置国内镜像源或使用代理:
```bash
# 使用镜像源
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
npm install
```
## 许可证
Copyright © 2026 SSH AI Agent Team
## 更新日志
### v0.2.0 (2026-06-16)
- 优化布局结构,AI 面板独立化
- 侧边栏永不消失,稳定固定宽度
- AI 助手支持快速开关控制
- 所有区域完善滚动条配置
- 版本号更新至 0.2.0
### v0.1.0
- 初始版本发布
- 基本 SSH 连接功能
- 基础 AI 助手集成
+2 -4
View File
@@ -1,18 +1,16 @@
const { execSync } = require('child_process');
// 删除所有代码签名环境变量
console.log('Packaging...');
delete process.env.CSC_LINK;
delete process.env.CSC_KEY_PASSWORD;
delete process.env.WIN_CSC_LINK;
delete process.env.WIN_CSC_KEY_PASSWORD;
try {
execSync('npx electron-builder --win --x64 --publish=never', {
stdio: 'inherit',
env: process.env,
cwd: __dirname
});
console.log('SUCCESS!');
console.log('\nSUCCESS!');
} catch (e) {
console.log('Failed:', e.message);
process.exit(1);
+127 -127
View File
@@ -29,7 +29,7 @@
"adm-zip": "^0.5.17",
"autoprefixer": "^10.4.19",
"concurrently": "^8.2.2",
"electron": "^30.0.8",
"electron": "^30.5.1",
"electron-builder": "^24.13.3",
"extract-zip": "^2.0.1",
"postcss": "^8.4.38",
@@ -1331,9 +1331,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
"integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==",
"cpu": [
"arm"
],
@@ -1345,9 +1345,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz",
"integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==",
"cpu": [
"arm64"
],
@@ -1359,9 +1359,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz",
"integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==",
"cpu": [
"arm64"
],
@@ -1373,9 +1373,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz",
"integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==",
"cpu": [
"x64"
],
@@ -1387,9 +1387,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz",
"integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==",
"cpu": [
"arm64"
],
@@ -1401,9 +1401,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz",
"integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==",
"cpu": [
"x64"
],
@@ -1415,9 +1415,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz",
"integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==",
"cpu": [
"arm"
],
@@ -1432,9 +1432,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz",
"integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==",
"cpu": [
"arm"
],
@@ -1449,9 +1449,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz",
"integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==",
"cpu": [
"arm64"
],
@@ -1466,9 +1466,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz",
"integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==",
"cpu": [
"arm64"
],
@@ -1483,9 +1483,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz",
"integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==",
"cpu": [
"loong64"
],
@@ -1500,9 +1500,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz",
"integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==",
"cpu": [
"loong64"
],
@@ -1517,9 +1517,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz",
"integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==",
"cpu": [
"ppc64"
],
@@ -1534,9 +1534,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz",
"integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==",
"cpu": [
"ppc64"
],
@@ -1551,9 +1551,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz",
"integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==",
"cpu": [
"riscv64"
],
@@ -1568,9 +1568,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz",
"integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==",
"cpu": [
"riscv64"
],
@@ -1585,9 +1585,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz",
"integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==",
"cpu": [
"s390x"
],
@@ -1602,9 +1602,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz",
"integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==",
"cpu": [
"x64"
],
@@ -1619,9 +1619,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz",
"integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==",
"cpu": [
"x64"
],
@@ -1636,9 +1636,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz",
"integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==",
"cpu": [
"x64"
],
@@ -1650,9 +1650,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz",
"integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==",
"cpu": [
"arm64"
],
@@ -1664,9 +1664,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz",
"integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==",
"cpu": [
"arm64"
],
@@ -1678,9 +1678,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz",
"integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==",
"cpu": [
"ia32"
],
@@ -1692,9 +1692,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz",
"integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==",
"cpu": [
"x64"
],
@@ -1706,9 +1706,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz",
"integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==",
"cpu": [
"x64"
],
@@ -2520,9 +2520,9 @@
}
},
"node_modules/axios": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2561,9 +2561,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.35",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
"integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3722,9 +3722,9 @@
}
},
"node_modules/electron": {
"version": "30.0.8",
"resolved": "https://registry.npmjs.org/electron/-/electron-30.0.8.tgz",
"integrity": "sha512-ivzXJJ/9gdb4oOw+5SDuaZpSInz8C+Z021dKZfFLMltKbDa4sSqt5cRBiUg7J36Z2kdus+Jai0bdHWutYE9wAA==",
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-30.5.1.tgz",
"integrity": "sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3928,9 +3928,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.371",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
"integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==",
"version": "1.5.373",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz",
"integrity": "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==",
"dev": true,
"license": "ISC"
},
@@ -4294,16 +4294,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -5050,9 +5050,9 @@
}
},
"node_modules/joi": {
"version": "17.13.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6074,9 +6074,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.3.tgz",
"integrity": "sha512-cDoO18VWCIWRsSaws3C23b0HXRxlUknttfdNcbXnf3IGHAPRNLlXPHc6dYiatOkxW0W4uZh524Plaaw5a7WEtQ==",
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6396,9 +6396,9 @@
}
},
"node_modules/rollup": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
"integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6412,31 +6412,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.61.1",
"@rollup/rollup-android-arm64": "4.61.1",
"@rollup/rollup-darwin-arm64": "4.61.1",
"@rollup/rollup-darwin-x64": "4.61.1",
"@rollup/rollup-freebsd-arm64": "4.61.1",
"@rollup/rollup-freebsd-x64": "4.61.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
"@rollup/rollup-linux-arm64-musl": "4.61.1",
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
"@rollup/rollup-linux-loong64-musl": "4.61.1",
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
"@rollup/rollup-linux-x64-gnu": "4.61.1",
"@rollup/rollup-linux-x64-musl": "4.61.1",
"@rollup/rollup-openbsd-x64": "4.61.1",
"@rollup/rollup-openharmony-arm64": "4.61.1",
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
"@rollup/rollup-win32-x64-gnu": "4.61.1",
"@rollup/rollup-win32-x64-msvc": "4.61.1",
"@rollup/rollup-android-arm-eabi": "4.62.0",
"@rollup/rollup-android-arm64": "4.62.0",
"@rollup/rollup-darwin-arm64": "4.62.0",
"@rollup/rollup-darwin-x64": "4.62.0",
"@rollup/rollup-freebsd-arm64": "4.62.0",
"@rollup/rollup-freebsd-x64": "4.62.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.0",
"@rollup/rollup-linux-arm-musleabihf": "4.62.0",
"@rollup/rollup-linux-arm64-gnu": "4.62.0",
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@rollup/rollup-linux-loong64-gnu": "4.62.0",
"@rollup/rollup-linux-loong64-musl": "4.62.0",
"@rollup/rollup-linux-ppc64-gnu": "4.62.0",
"@rollup/rollup-linux-ppc64-musl": "4.62.0",
"@rollup/rollup-linux-riscv64-gnu": "4.62.0",
"@rollup/rollup-linux-riscv64-musl": "4.62.0",
"@rollup/rollup-linux-s390x-gnu": "4.62.0",
"@rollup/rollup-linux-x64-gnu": "4.62.0",
"@rollup/rollup-linux-x64-musl": "4.62.0",
"@rollup/rollup-openbsd-x64": "4.62.0",
"@rollup/rollup-openharmony-arm64": "4.62.0",
"@rollup/rollup-win32-arm64-msvc": "4.62.0",
"@rollup/rollup-win32-ia32-msvc": "4.62.0",
"@rollup/rollup-win32-x64-gnu": "4.62.0",
"@rollup/rollup-win32-x64-msvc": "4.62.0",
"fsevents": "~2.3.2"
}
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ssh-ai-agent",
"version": "0.1.0",
"version": "0.2.0",
"description": "智能SSH终端 - 集成 AI 助手的远程运维桌面客户端",
"author": {
"name": "SSH AI Agent Team"
@@ -38,7 +38,7 @@
"adm-zip": "^0.5.17",
"autoprefixer": "^10.4.19",
"concurrently": "^8.2.2",
"electron": "^30.0.8",
"electron": "^30.5.1",
"electron-builder": "^24.13.3",
"extract-zip": "^2.0.1",
"postcss": "^8.4.38",
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能SSH终端</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [
tailwindcss({
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
}),
autoprefixer(),
],
};
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react';
import ResizablePanel from './components/layout/ResizablePanel';
import Sidebar, { type SidebarTab } from './components/layout/Sidebar';
import TitleBar from './components/layout/TitleBar';
import SessionList from './components/session/SessionList';
import TerminalPanel from './components/terminal/TerminalPanel';
import AiChatPanel from './components/ai/AiChatPanel';
import ModelSettings from './components/ai/ModelSettings';
import { useSessionStore } from './stores/sessionStore';
import { useAiChatStore } from './stores/aiChatStore';
const TAB_TITLES: Record<SidebarTab, string> = {
sessions: '会话列表',
tunnels: '隧道管理',
mcp: 'MCP',
community: '社区',
models: 'AI 模型',
settings: '设置',
};
const AI_PANEL_WIDTH = 380;
export default function App() {
const [activeTab, setActiveTab] = useState<SidebarTab>('sessions');
const [panelVisible, setPanelVisible] = useState(true);
const [aiPanelVisible, setAiPanelVisible] = useState(false);
const { setSessions, setGroups, setActiveSessionId, setSessionStatus } = useSessionStore();
const { setModelConfigs } = useAiChatStore();
// 初始化:加载会话、分组、模型配置
useEffect(() => {
const api = window.electronAPI;
Promise.all([
api.sessionList().then(setSessions),
api.groupList().then(setGroups).catch(() => undefined),
api.modelList().then(setModelConfigs).catch(console.error),
]).catch(console.error);
}, []);
// 监听会话状态变化
useEffect(() => {
const api = window.electronAPI;
const unsub = api.onSessionStatus((sessionId, status) => {
setSessionStatus(sessionId, status as any);
});
return unsub;
}, []);
const handleTabChange = (tab: SidebarTab) => {
if (tab === activeTab && panelVisible) {
setPanelVisible(false);
} else {
setActiveTab(tab);
setPanelVisible(true);
}
};
const renderPanel = () => {
switch (activeTab) {
case 'sessions':
return <SessionList />;
case 'models':
return <ModelSettings />;
case 'tunnels':
return <div className="p-3 text-gray-400 text-sm"></div>;
case 'mcp':
return <div className="p-3 text-gray-400 text-sm">MCP</div>;
case 'community':
return <div className="p-3 text-gray-400 text-sm"></div>;
case 'settings':
return <div className="p-3 text-gray-400 text-sm"></div>;
default:
return null;
}
};
return (
<div className="h-full w-full flex flex-col">
{/* 自定义标题栏 */}
<TitleBar />
{/* 主内容区 */}
<div className="flex-1 min-h-0 flex overflow-hidden">
{/* ① 功能导航(固定宽度,永不收缩) */}
<Sidebar
activeTab={activeTab}
onTabChange={handleTabChange}
aiPanelVisible={aiPanelVisible}
onToggleAiPanel={() => setAiPanelVisible(v => !v)}
/>
{/* ② 主工作区:管理面板 + 终端(占据剩余空间) */}
<div className="flex-1 min-w-0 overflow-hidden">
<ResizablePanel direction="horizontal" initialSize={20} minSize={12} maxSize={35}>
{/* 管理面板 */}
<div className="h-full">
<div className={`h-full bg-gray-900 ${panelVisible ? '' : 'hidden'}`}>
<div className="h-full flex flex-col">
<div className="px-3 py-2 border-b border-gray-800 text-sm font-medium text-gray-300 flex-shrink-0">
{TAB_TITLES[activeTab]}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{renderPanel()}
</div>
</div>
</div>
</div>
{/* 命令终端 */}
<TerminalPanel />
</ResizablePanel>
</div>
{/* ③ AI对话区(独立侧栏,可收起) */}
{aiPanelVisible && (
<div
className="flex-shrink-0 h-full border-l border-gray-800 overflow-hidden"
style={{ width: AI_PANEL_WIDTH }}
>
<AiChatPanel onClose={() => setAiPanelVisible(false)} />
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,159 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Loader2, X, StopCircle, PanelRightClose } from 'lucide-react';
import { useAiChatStore } from '../../stores/aiChatStore';
import { useSessionStore } from '../../stores/sessionStore';
import ToolCallCard from './ToolCallCard';
const api = window.electronAPI;
interface AiChatPanelProps {
onClose?: () => void;
}
export default function AiChatPanel({ onClose }: AiChatPanelProps) {
const { messages, isStreaming, pendingToolCalls, activeModelConfigId, addMessage, updateLastAssistantMessage, setStreaming, addToolCall, updateToolCall, removeToolCall, clearMessages } = useAiChatStore();
const { activeSessionId } = useSessionStore();
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, pendingToolCalls]);
// 监听 AI 事件
useEffect(() => {
const unsubs = [
api.onAiChatStream((chunk) => {
if (chunk.done) {
setStreaming(false);
} else if (chunk.content) {
// chunk.content 是 delta(增量),store 会 append 到最后一条 assistant 消息
updateLastAssistantMessage(chunk.content);
}
}),
api.onAiToolCall((tc) => {
addToolCall({ id: tc.toolCallId, name: tc.toolName, arguments: tc.args, status: 'pending' });
}),
api.onAiToolResult((result) => {
updateToolCall(result.toolCallId, { status: result.success ? 'completed' : 'error', result: { success: result.success, output: result.output, error: result.error } });
}),
api.onAiError((error) => {
addMessage({ id: `msg_${Date.now()}`, role: 'assistant', content: `${error.message}`, timestamp: Date.now() });
setStreaming(false);
}),
];
return () => unsubs.forEach((fn) => fn());
}, []);
const handleSend = () => {
const trimmed = input.trim();
if (!trimmed || !activeModelConfigId) return;
addMessage({ id: `msg_${Date.now()}`, role: 'user', content: trimmed, timestamp: Date.now() });
addMessage({ id: `msg_${Date.now()}_asst`, role: 'assistant', content: '', timestamp: Date.now() });
api.aiChat(activeSessionId, trimmed, activeModelConfigId);
setStreaming(true);
setInput('');
};
const handleToolAction = (toolCallId: string, action: string) => {
api.aiToolAction(toolCallId, action);
if (action === 'cancel') removeToolCall(toolCallId);
else updateToolCall(toolCallId, { status: 'approved' });
};
return (
<div className="h-full flex flex-col bg-gray-900">
{/* 头部 */}
<div className="flex items-center h-9 px-3 border-b border-gray-800 gap-2 flex-shrink-0">
<Bot size={14} className="text-blue-400" />
<span className="text-xs text-gray-400 flex-1">AI </span>
<select
value={activeModelConfigId || ''}
onChange={(e) => useAiChatStore.getState().setActiveModelConfigId(e.target.value || null)}
className="bg-gray-800 border border-gray-700 rounded text-xs text-gray-300 px-2 py-0.5 max-w-[120px] focus:outline-none focus:border-blue-500"
>
<option value=""></option>
{useAiChatStore.getState().modelConfigs.filter((c) => c.enabled).map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
{isStreaming && (
<button onClick={() => api.aiInterrupt()} className="p-1 rounded hover:bg-gray-700 text-red-400 transition-colors" title="停止">
<StopCircle size={14} />
</button>
)}
{messages.length > 0 && (
<button onClick={clearMessages} className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-red-400 transition-colors" title="清空">
<X size={14} />
</button>
)}
{onClose && (
<button onClick={onClose} className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors" title="收起面板">
<PanelRightClose size={14} />
</button>
)}
</div>
{/* 消息区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto p-3 space-y-3 min-h-0">
{messages.length === 0 && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 text-sm">
<Bot size={32} className="mx-auto mb-2 text-gray-600" />
<p> AI </p>
<p className="text-xs mt-1"></p>
</div>
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 rounded bg-blue-600/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bot size={14} className="text-blue-400" />
</div>
)}
<div className={`max-w-[85%] px-3 py-2 rounded-lg text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-200'}`}>
{msg.role === 'assistant' && msg.content === '' && isStreaming ? (
<div className="flex items-center gap-1 text-gray-400"><Loader2 size={12} className="animate-spin" />...</div>
) : (
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
)}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 rounded bg-gray-700 flex items-center justify-center flex-shrink-0 mt-0.5">
<User size={14} className="text-gray-400" />
</div>
)}
</div>
))}
{Array.from(pendingToolCalls.values()).map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} onAction={handleToolAction} />
))}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 - 固定在底部 */}
<div className="border-t border-gray-800 p-2 flex-shrink-0">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
placeholder={activeModelConfigId ? '输入消息与 AI 助手对话...' : '请先在左侧配置 AI 模型...'}
disabled={!activeModelConfigId || isStreaming}
rows={1}
className="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-gray-200 resize-none focus:border-blue-500 focus:outline-none disabled:opacity-50 transition-colors"
style={{ maxHeight: '80px', minHeight: '32px' }}
/>
<button
onClick={handleSend}
disabled={!input.trim() || !activeModelConfigId || isStreaming}
className="p-2 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
>
<Send size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { Plus, Trash2, Check } from 'lucide-react';
import { useAiChatStore } from '../../stores/aiChatStore';
import type { ModelConfig, ModelProvider, ModelPreset } from '../../../shared/types/ai';
const api = window.electronAPI;
export default function ModelSettings() {
const { modelConfigs, addModelConfig, removeModelConfig, setActiveModelConfigId } = useAiChatStore();
const [presets, setPresets] = useState<ModelPreset[]>([]);
const [showAdd, setShowAdd] = useState(false);
useEffect(() => {
api.modelPresets().then(setPresets).catch(console.error);
}, []);
const handleAddPreset = async (preset: ModelPreset) => {
const config = await api.modelSave({
name: preset.name,
provider: preset.provider,
endpoint: preset.endpoint,
model: preset.model,
apiKey: '',
maxTokens: preset.maxTokens,
enabled: true,
isPreset: true,
});
addModelConfig(config);
if (!useAiChatStore.getState().activeModelConfigId) {
setActiveModelConfigId(config.id);
}
};
const handleAddCustom = async (config: Omit<ModelConfig, 'id' | 'createdAt' | 'updatedAt'>) => {
const saved = await api.modelSave(config);
addModelConfig(saved);
setActiveModelConfigId(saved.id);
setShowAdd(false);
};
const handleDelete = async (id: string) => {
await api.modelDelete(id);
removeModelConfig(id);
};
return (
<div className="flex flex-col h-full">
<div className="px-3 py-2 flex items-center justify-between border-b border-gray-800">
<span className="text-xs text-gray-400">{modelConfigs.length} </span>
<button onClick={() => setShowAdd(true)} className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white" title="添加模型">
<Plus size={16} />
</button>
</div>
{/* 已配置的模型 */}
<div className="p-2 space-y-1">
{modelConfigs.map((mc) => (
<div key={mc.id} className="flex items-center gap-2 px-2 py-1.5 bg-gray-800 rounded text-xs group">
<div className={`w-2 h-2 rounded-full ${mc.enabled ? 'bg-green-500' : 'bg-gray-600'}`} />
<div className="flex-1 min-w-0">
<div className="text-gray-200 truncate">{mc.name}</div>
<div className="text-gray-500 truncate">{mc.model}</div>
</div>
<button onClick={() => setActiveModelConfigId(mc.id)} className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-gray-600 text-blue-400" title="使用此模型">
<Check size={12} />
</button>
<button onClick={() => handleDelete(mc.id)} className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-gray-600 text-red-400" title="删除">
<Trash2 size={12} />
</button>
</div>
))}
</div>
{/* 快速添加预设 */}
{presets.length > 0 && (
<>
<div className="px-3 py-1 text-xs text-gray-500 border-t border-gray-800"></div>
<div className="p-2 space-y-1">
{presets.map((p) => (
<button
key={p.id}
onClick={() => handleAddPreset(p)}
className="w-full text-left px-2 py-1.5 bg-gray-800/50 rounded text-xs hover:bg-gray-700 transition-colors"
>
<div className="text-gray-300">{p.name}</div>
<div className="text-gray-500 truncate">{p.endpoint.replace('https://', '').replace('http://', '')}</div>
</button>
))}
</div>
</>
)}
{/* 自定义添加 */}
{showAdd && (
<CustomModelDialog
onConfirm={handleAddCustom}
onCancel={() => setShowAdd(false)}
/>
)}
</div>
);
}
function CustomModelDialog({ onConfirm, onCancel }: { onConfirm: (c: Omit<ModelConfig, 'id' | 'createdAt' | 'updatedAt'>) => void; onCancel: () => void }) {
const [name, setName] = useState('');
const [provider, setProvider] = useState<ModelProvider>('custom');
const [endpoint, setEndpoint] = useState('');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
const [maxTokens, setMaxTokens] = useState('4096');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onConfirm({
name: name || model,
provider,
endpoint,
model,
apiKey,
maxTokens: parseInt(maxTokens, 10) || 4096,
enabled: true,
});
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg shadow-xl w-[420px]">
<div className="px-4 py-3 border-b border-gray-700 text-sm font-medium"></div>
<form onSubmit={handleSubmit} className="p-4 space-y-3">
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="例如:我的 GPT-4o"
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="text" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="https://api.openai.com/v1" required
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="text" value={model} onChange={(e) => setModel(e.target.value)} placeholder="gpt-4o / deepseek-chat / qwen-plus" required
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..."
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"> Tokens</label>
<input type="number" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)}
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"></button>
<button type="submit" className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"></button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,97 @@
import { X, Copy, Terminal as TermIcon, Bot } from 'lucide-react';
import type { ToolCall } from '../../../shared/types/ai';
interface ToolCallCardProps {
toolCall: ToolCall;
onAction: (toolCallId: string, action: string) => void;
}
export default function ToolCallCard({ toolCall, onAction }: ToolCallCardProps) {
const isCommand = toolCall.name === 'execute_command';
const isFileEdit = toolCall.name === 'write_file';
const command = toolCall.arguments.command as string || '';
const filePath = toolCall.arguments.path as string || '';
const isExecuting = toolCall.status === 'executing' || toolCall.status === 'approved';
const isDone = toolCall.status === 'completed' || toolCall.status === 'error';
return (
<div className="bg-gray-800/80 border border-gray-700 rounded-lg p-3 space-y-2">
{/* 标题 */}
<div className="flex items-center gap-2 text-xs">
<Bot size={14} className="text-blue-400" />
<span className="text-gray-300 font-medium">
{isCommand ? '执行命令' : isFileEdit ? '编辑文件' : toolCall.name}
</span>
{isExecuting && <span className="text-yellow-400 animate-pulse">...</span>}
{toolCall.status === 'completed' && <span className="text-green-400"></span>}
{toolCall.status === 'error' && <span className="text-red-400"></span>}
</div>
{/* 命令/文件内容 */}
{isCommand && (
<code className="block bg-gray-950 text-green-400 px-3 py-2 rounded text-sm font-mono whitespace-pre-wrap break-all">
{command}
</code>
)}
{isFileEdit && (
<div className="text-sm text-gray-300">
: <code className="text-yellow-400">{filePath}</code>
</div>
)}
{/* 操作按钮 - 仅 pending 状态显示 */}
{toolCall.status === 'pending' && (
<div className="flex items-center gap-2">
{isCommand ? (
<>
<button onClick={() => onAction(toolCall.id, 'cancel')}
className="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded hover:bg-gray-600 flex items-center gap-1">
<X size={12} />
</button>
<button onClick={() => { navigator.clipboard.writeText(command); }}
className="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded hover:bg-gray-600 flex items-center gap-1">
<Copy size={12} />
</button>
<button onClick={() => onAction(toolCall.id, 'terminal_exec')}
className="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded hover:bg-gray-600 flex items-center gap-1">
<TermIcon size={12} />
</button>
<button onClick={() => onAction(toolCall.id, 'ai_exec')}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1">
<Bot size={12} /> AI执行
</button>
</>
) : isFileEdit ? (
<>
<button onClick={() => onAction(toolCall.id, 'cancel')}
className="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded hover:bg-gray-600 flex items-center gap-1">
<X size={12} />
</button>
<button onClick={() => onAction(toolCall.id, 'manual_edit')}
className="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded hover:bg-gray-600">
</button>
<button onClick={() => onAction(toolCall.id, 'allow')}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">
</button>
</>
) : (
<button onClick={() => onAction(toolCall.id, 'ai_exec')}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">
</button>
)}
</div>
)}
{/* 执行结果 */}
{toolCall.result && isDone && (
<div className={`text-xs p-2 rounded ${toolCall.result.success ? 'bg-green-900/30 text-green-300' : 'bg-red-900/30 text-red-300'}`}>
<pre className="whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{toolCall.result.output}</pre>
{toolCall.result.error && <p className="mt-1 text-red-400">: {toolCall.result.error}</p>}
</div>
)}
</div>
);
}
@@ -0,0 +1,72 @@
import { useState, useCallback, useRef, type ReactNode, type MouseEvent } from 'react';
interface ResizablePanelProps {
direction: 'horizontal' | 'vertical';
initialSize: number;
minSize: number;
maxSize: number;
children: [ReactNode, ReactNode];
className?: string;
}
export default function ResizablePanel({
direction, initialSize, minSize, maxSize, children, className = '',
}: ResizablePanelProps) {
const [size, setSize] = useState(initialSize);
const isDragging = useRef(false);
const startPos = useRef(0);
const startSize = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const onMouseDown = useCallback(
(e: MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startPos.current = direction === 'horizontal' ? e.clientX : e.clientY;
startSize.current = size;
const onMouseMove = (e: globalThis.MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const total = direction === 'horizontal' ? rect.width : rect.height;
const delta = direction === 'horizontal' ? e.clientX - startPos.current : e.clientY - startPos.current;
const newSize = Math.min(maxSize, Math.max(minSize, startSize.current + (delta / total) * 100));
setSize(newSize);
};
const onMouseUp = () => {
isDragging.current = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
},
[direction, size, minSize, maxSize]
);
const isH = direction === 'horizontal';
return (
<div ref={containerRef} className={`flex ${isH ? 'flex-row' : 'flex-col'} h-full w-full ${className}`}>
<div style={{ [isH ? 'width' : 'height']: `${size}%` }} className="overflow-hidden min-w-0 min-h-0">
<div className="h-full">
{children[0]}
</div>
</div>
<div
onMouseDown={onMouseDown}
className={`flex-shrink-0 ${isH ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'} bg-gray-700/50 hover:bg-blue-500/50 active:bg-blue-500 transition-colors`}
/>
<div style={{ [isH ? 'width' : 'height']: `${100 - size}%` }} className="overflow-hidden min-w-0 min-h-0 flex-1">
<div className="h-full">
{children[1]}
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { Server, Brain, Terminal, Network, Users, Settings, Bot, type LucideIcon } from 'lucide-react';
export type SidebarTab = 'sessions' | 'tunnels' | 'mcp' | 'community' | 'models' | 'settings';
interface SidebarItem { id: SidebarTab; icon: LucideIcon; label: string; }
const items: SidebarItem[] = [
{ id: 'sessions', icon: Server, label: '会话' },
{ id: 'tunnels', icon: Network, label: '隧道' },
{ id: 'mcp', icon: Terminal, label: 'MCP' },
{ id: 'community', icon: Users, label: '社区' },
{ id: 'models', icon: Brain, label: 'AI 模型' },
{ id: 'settings', icon: Settings, label: '设置' },
];
interface SidebarProps {
activeTab: SidebarTab;
onTabChange: (tab: SidebarTab) => void;
aiPanelVisible: boolean;
onToggleAiPanel: () => void;
}
export default function Sidebar({ activeTab, onTabChange, aiPanelVisible, onToggleAiPanel }: SidebarProps) {
return (
<div className="w-12 bg-gray-900 border-r border-gray-800 flex flex-col items-center py-2 gap-1 flex-shrink-0">
{items.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
title={item.label}
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
isActive ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
}`}
>
<Icon size={20} />
</button>
);
})}
{/* 底部 AI 助手开关 */}
<div className="mt-auto">
<button
onClick={onToggleAiPanel}
title="AI 助手"
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
aiPanelVisible ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
}`}
>
<Bot size={20} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { X, Minus, Square } from 'lucide-react';
declare global {
interface Window {
electronAPI: {
windowClose: () => void;
windowMinimize: () => void;
windowMaximize: () => void;
[key: string]: any;
};
}
}
export default function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
// 双击标题栏最大化
const handleDblClick = () => {
window.electronAPI?.windowMaximize();
};
window.addEventListener('dblclick', handleDblClick);
return () => window.removeEventListener('dblclick', handleDblClick);
}, []);
if (!window.electronAPI) return null;
return (
<div
className="flex items-center justify-between h-8 bg-[#0a0a0f] select-none"
style={{ WebkitAppRegion: 'drag' } as any}
>
<div className="flex items-center px-3 gap-2">
<span className="text-sm text-gray-300">SSH终端</span>
</div>
<div
className="flex items-center h-full"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
<button
onClick={() => window.electronAPI.windowMinimize()}
className="h-full px-3 hover:bg-gray-700 transition-colors"
>
<Minus className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => {
window.electronAPI.windowMaximize();
setIsMaximized(!isMaximized);
}}
className="h-full px-3 hover:bg-gray-700 transition-colors"
>
<Square className={`w-3.5 h-3.5 text-gray-400 ${isMaximized ? 'scale-90' : ''}`} />
</button>
<button
onClick={() => window.electronAPI.windowClose()}
className="h-full px-3 hover:bg-red-600 transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,667 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import {
Plus, Trash2, Plug, ChevronRight, ChevronDown,
FolderPlus, Folder, FolderOpen, Monitor, MoreVertical, Edit3, Server, Terminal, Pencil,
} from 'lucide-react';
import { useSessionStore } from '../../stores/sessionStore';
import type { SessionConfig, SessionGroup, AuthType } from '../../../shared/types/session';
const api = window.electronAPI;
export default function SessionList() {
const {
groups, sessions, sessionStatuses, activeSessionId,
addSession, addGroup, updateGroup, removeGroup, updateSession, removeSession,
setActiveSessionId, setSessionStatus,
} = useSessionStore();
const [showCreateSession, setShowCreateSession] = useState(false);
const [createGroupParentId, setCreateGroupParentId] = useState<string | null>(null);
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [editingGroup, setEditingGroup] = useState<SessionGroup | null>(null);
const [editingSession, setEditingSession] = useState<SessionConfig | null>(null);
const [createSessionGroupId, setCreateSessionGroupId] = useState<string | null>(null);
// ---- 会话操作 ----
const handleCreateSession = async (
config: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt' | 'order'>
) => {
try {
const session = await api.sessionCreate(config);
addSession(session);
setShowCreateSession(false);
setCreateSessionGroupId(null);
} catch (err) {
console.error('创建会话失败:', err);
}
};
const handleUpdateSession = async (
id: string,
updates: Partial<Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt' | 'order'>>
) => {
try {
const updated = await api.sessionUpdate(id, updates);
if (updated) updateSession(id, updated);
setEditingSession(null);
} catch (err) {
console.error('更新会话失败:', err);
}
};
const handleConnect = async (session: SessionConfig) => {
console.log(`[前端] handleConnect 触发: ${session.name} (${session.id})`);
setActiveSessionId(session.id);
setSessionStatus(session.id, 'connecting');
console.log(`[前端] 设置 status=connecting, 调用 api.sessionConnect...`);
try {
await api.sessionConnect(session.id);
console.log(`[前端] api.sessionConnect resolve`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSessionStatus(session.id, 'error');
console.error(`[前端] [SSH连接失败] ${session.name} (${session.username}@${session.host}:${session.port}):`, msg);
}
};
const handleDisconnect = async (sessionId: string) => {
await api.sessionDisconnect(sessionId);
setSessionStatus(sessionId, 'disconnected');
if (activeSessionId === sessionId) setActiveSessionId(null);
};
const handleDeleteSession = async (sessionId: string) => {
if (!confirm('确定删除该会话?')) return;
await api.sessionDelete(sessionId);
removeSession(sessionId);
if (activeSessionId === sessionId) setActiveSessionId(null);
};
// ---- 分组操作 ----
const handleCreateGroup = async (name: string, parentId: string | null) => {
const group = await api.groupCreate({ name, parentId });
addGroup(group);
setShowCreateGroup(false);
setCreateGroupParentId(null);
};
const handleRenameGroup = async (id: string, name: string) => {
const updated = await api.groupUpdate(id, { name });
if (updated) updateGroup(id, { name: updated.name });
setEditingGroup(null);
};
const handleToggleCollapse = (id: string, collapsed: boolean) => {
updateGroup(id, { collapsed });
api.groupUpdate(id, { collapsed }).catch(console.error);
};
const handleDeleteGroup = async (id: string) => {
if (!confirm('确定删除该分组? 分组下的子分组和所有会话都将被删除。')) return;
await api.groupDelete(id);
removeGroup(id);
};
const openCreateSessionInGroup = (groupId: string | null) => {
setEditingSession(null);
setCreateSessionGroupId(groupId);
setShowCreateSession(true);
};
const openEditSession = (session: SessionConfig) => {
setShowCreateSession(false);
setCreateSessionGroupId(null);
setEditingSession(session);
};
return (
<div className="flex flex-col h-full">
<div className="px-3 py-1.5 flex items-center justify-between border-b border-gray-800">
<span className="text-xs text-gray-500">{sessions.length} </span>
<div className="flex items-center gap-1">
<button
onClick={() => openCreateSessionInGroup(null)}
className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white"
title="新建会话 (Ctrl+N)"
>
<Plus size={14} />
</button>
<button
onClick={() => { setCreateGroupParentId(null); setShowCreateGroup(true); }}
className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white"
title="新建分组 (Ctrl+Shift+N)"
>
<FolderPlus size={14} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto py-1">
{groups.length === 0 && sessions.filter((s) => !s.groupId).length === 0 ? (
<div className="p-6 text-center text-gray-500 text-xs">
+
<br />
<br />
<span className="text-gray-600">/</span>
</div>
) : (
<TreeList
groups={groups}
sessions={sessions}
sessionStatuses={sessionStatuses}
activeSessionId={activeSessionId}
onToggleCollapse={handleToggleCollapse}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onDeleteSession={handleDeleteSession}
onEditSession={openEditSession}
onEditGroup={setEditingGroup}
onDeleteGroup={handleDeleteGroup}
onCreateGroupHere={(parentId) => {
setCreateGroupParentId(parentId);
setShowCreateGroup(true);
}}
onCreateSessionHere={openCreateSessionInGroup}
/>
)}
</div>
{showCreateSession && (
<CreateSessionDialog
groups={groups}
defaultGroupId={createSessionGroupId}
onConfirm={handleCreateSession}
onCancel={() => { setShowCreateSession(false); setCreateSessionGroupId(null); }}
/>
)}
{editingSession && (
<CreateSessionDialog
groups={groups}
editingSession={editingSession}
onConfirm={(config) => handleUpdateSession(editingSession.id, config)}
onCancel={() => setEditingSession(null)}
/>
)}
{showCreateGroup && (
<PromptDialog
title="新建分组"
placeholder="分组名称"
onConfirm={(name) => handleCreateGroup(name, createGroupParentId)}
onCancel={() => { setShowCreateGroup(false); setCreateGroupParentId(null); }}
/>
)}
{editingGroup && (
<PromptDialog
title="重命名分组"
placeholder="分组名称"
initialValue={editingGroup.name}
onConfirm={(name) => handleRenameGroup(editingGroup.id, name)}
onCancel={() => setEditingGroup(null)}
/>
)}
</div>
);
}
// ============================================
// 树形渲染
// ============================================
interface TreeListProps {
groups: SessionGroup[];
sessions: SessionConfig[];
sessionStatuses: Record<string, any>;
activeSessionId: string | null;
onToggleCollapse: (id: string, collapsed: boolean) => void;
onConnect: (s: SessionConfig) => void;
onDisconnect: (id: string) => void;
onDeleteSession: (id: string) => void;
onEditSession: (s: SessionConfig) => void;
onEditGroup: (g: SessionGroup) => void;
onDeleteGroup: (id: string) => void;
onCreateGroupHere: (parentId: string) => void;
onCreateSessionHere: (groupId: string) => void;
}
function TreeList(props: TreeListProps) {
const { groups, sessions } = props;
const rootGroups = useMemo(
() => groups.filter((g) => g.parentId === null).sort((a, b) => a.order - b.order),
[groups]
);
const rootSessions = useMemo(
() => sessions.filter((s) => !s.groupId).sort((a, b) => a.order - b.order),
[sessions]
);
return (
<div>
{rootGroups.map((g) => (
<TreeNode key={g.id} group={g} depth={0} {...props} />
))}
{rootSessions.map((s) => (
<SessionRow key={s.id} session={s} depth={0} {...props} />
))}
</div>
);
}
function TreeNode({
group, depth, groups, sessions, ...handlers
}: TreeListProps & { group: SessionGroup; depth: number }) {
const childGroups = useMemo(
() => groups.filter((g) => g.parentId === group.id).sort((a, b) => a.order - b.order),
[groups, group.id]
);
const childSessions = useMemo(
() => sessions.filter((s) => s.groupId === group.id).sort((a, b) => a.order - b.order),
[sessions, group.id]
);
const [menuOpen, setMenuOpen] = useState(false);
const menuPos = useRef<{ x: number; y: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const close = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', close);
document.addEventListener('contextmenu', close);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('contextmenu', close);
};
}, [menuOpen]);
const paddingLeft = 8 + depth * 12;
const openContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
menuPos.current = { x: e.clientX, y: e.clientY };
setMenuOpen(true);
};
return (
<>
<div
className="group flex items-center gap-1 pr-1 py-1 hover:bg-gray-800/50 cursor-pointer text-sm"
style={{ paddingLeft }}
onClick={() => handlers.onToggleCollapse(group.id, !group.collapsed)}
onContextMenu={openContextMenu}
>
{group.collapsed ? <ChevronRight size={12} className="text-gray-500 flex-shrink-0" />
: <ChevronDown size={12} className="text-gray-500 flex-shrink-0" />}
{group.collapsed
? <Folder size={14} className="text-yellow-500 flex-shrink-0" />
: <FolderOpen size={14} className="text-yellow-500 flex-shrink-0" />}
<span className="text-gray-200 truncate flex-1">{group.name}</span>
<div className="relative" ref={menuRef} onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v); }}
className="p-0.5 rounded hover:bg-gray-700 text-gray-500 hover:text-white"
title="更多"
>
<MoreVertical size={12} />
</button>
{menuOpen && (
<ContextMenu
menuRef={menuRef}
pos={menuPos.current}
onClose={() => setMenuOpen(false)}
items={[
{ icon: <Plus size={12} />, label: '新建会话', onClick: () => handlers.onCreateSessionHere(group.id) },
{ icon: <FolderPlus size={12} />, label: '新建子分组', onClick: () => handlers.onCreateGroupHere(group.id) },
{ icon: <Pencil size={12} />, label: '重命名', onClick: () => handlers.onEditGroup(group) },
{ divider: true },
{ icon: <Trash2 size={12} />, label: '删除分组', danger: true, onClick: () => handlers.onDeleteGroup(group.id) },
]}
/>
)}
</div>
</div>
{!group.collapsed && (
<>
{childGroups.map((g) => (
<TreeNode key={g.id} group={g} depth={depth + 1} groups={groups} sessions={sessions} {...handlers} />
))}
{childSessions.map((s) => (
<SessionRow key={s.id} session={s} depth={depth + 1} groups={groups} sessions={sessions} {...handlers} />
))}
</>
)}
</>
);
}
function SessionRow({
session, depth, sessionStatuses, activeSessionId, ...handlers
}: TreeListProps & { session: SessionConfig; depth: number }) {
const status = sessionStatuses[session.id] || 'disconnected';
const isActive = activeSessionId === session.id;
const [menuOpen, setMenuOpen] = useState(false);
const menuPos = useRef<{ x: number; y: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const paddingLeft = 8 + depth * 12 + 14;
useEffect(() => {
if (!menuOpen) return;
const close = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', close);
document.addEventListener('contextmenu', close);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('contextmenu', close);
};
}, [menuOpen]);
const openContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
menuPos.current = { x: e.clientX, y: e.clientY };
setMenuOpen(true);
};
const items: ContextMenuItem[] = [
status === 'connected'
? { icon: <Plug size={12} />, label: '断开', onClick: () => handlers.onDisconnect(session.id) }
: { icon: <Plug size={12} />, label: '连接', onClick: () => handlers.onConnect(session) },
{ icon: <Pencil size={12} />, label: '编辑', onClick: () => handlers.onEditSession(session) },
{ divider: true },
{ icon: <Trash2 size={12} />, label: '删除', danger: true, onClick: () => handlers.onDeleteSession(session.id) },
];
return (
<div
className={`group flex items-center gap-1.5 pr-1 py-1 cursor-pointer transition-colors ${isActive ? 'bg-blue-600/20 border-l-2 border-blue-500' : 'hover:bg-gray-800/50'}`}
style={{ paddingLeft }}
onClick={() => {
if (status === 'disconnected' || status === 'error') handlers.onConnect(session);
else if (status === 'connected') handlers.onDisconnect(session.id);
}}
onContextMenu={openContextMenu}
>
<Monitor size={13} className="text-gray-500 flex-shrink-0" />
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
status === 'connected' ? 'bg-green-500'
: status === 'connecting' ? 'bg-yellow-500 animate-pulse'
: status === 'error' ? 'bg-red-500'
: 'bg-gray-600'
}`} />
<span className="text-sm text-gray-200 truncate flex-1">{session.name}</span>
<div className="relative" ref={menuRef} onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v); }}
className="p-0.5 rounded hover:bg-gray-700 text-gray-500 hover:text-white"
title="更多"
>
<MoreVertical size={12} />
</button>
{menuOpen && (
<ContextMenu
menuRef={menuRef}
pos={menuPos.current}
onClose={() => setMenuOpen(false)}
items={items}
/>
)}
</div>
</div>
);
}
// ============================================
// 通用右键菜单
// ============================================
interface ContextMenuItem {
icon?: React.ReactNode;
label?: string;
onClick?: () => void;
divider?: boolean;
danger?: boolean;
}
function ContextMenu({
menuRef, pos, onClose, items,
}: {
menuRef: React.RefObject<HTMLDivElement>;
pos: { x: number; y: number } | null;
onClose: () => void;
items: ContextMenuItem[];
}) {
// 根据 pos 决定是相对定位(按按钮)还是固定定位(按鼠标)
// 右键触发时 pos 非空,使用 fixed;点击 ... 按钮时 pos 为 null,使用 absolute
if (pos) {
return (
<div
ref={menuRef}
onClick={onClose}
className="fixed z-50 bg-gray-800 border border-gray-700 rounded shadow-lg py-1 w-32 text-xs"
style={{ left: pos.x, top: pos.y }}
>
{items.map((item, i) =>
item.divider ? (
<div key={i} className="my-1 border-t border-gray-700" />
) : (
<button
key={i}
onClick={item.onClick}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 ${
item.danger
? 'hover:bg-red-600/50 text-red-300'
: 'hover:bg-gray-700 text-gray-200'
}`}
>
{item.icon}
{item.label}
</button>
)
)}
</div>
);
}
return (
<div
ref={menuRef}
onClick={onClose}
className="absolute right-0 top-5 z-20 bg-gray-800 border border-gray-700 rounded shadow-lg py-1 w-32 text-xs"
>
{items.map((item, i) =>
item.divider ? (
<div key={i} className="my-1 border-t border-gray-700" />
) : (
<button
key={i}
onClick={item.onClick}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 ${
item.danger
? 'hover:bg-red-600/50 text-red-300'
: 'hover:bg-gray-700 text-gray-200'
}`}
>
{item.icon}
{item.label}
</button>
)
)}
</div>
);
}
// ============================================
// 创建/编辑会话对话框
// ============================================
function CreateSessionDialog({
groups, defaultGroupId, editingSession, onConfirm, onCancel,
}: {
groups: SessionGroup[];
defaultGroupId: string | null;
editingSession?: SessionConfig | null;
onConfirm: (c: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt' | 'order'>) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(editingSession?.name ?? '');
const [host, setHost] = useState(editingSession?.host ?? '');
const [port, setPort] = useState(editingSession?.port?.toString() ?? '22');
const [username, setUsername] = useState(editingSession?.username ?? 'root');
const [authType, setAuthType] = useState<AuthType>(editingSession?.authType ?? 'password');
const [password, setPassword] = useState(editingSession?.password ?? '');
const [privateKey, setPrivateKey] = useState(editingSession?.privateKey ?? '');
const [groupId, setGroupId] = useState<string | null>(
editingSession?.groupId ?? defaultGroupId ?? null
);
const isEdit = !!editingSession;
const title = isEdit ? '编辑会话' : '新建连接';
const passwordMissing = authType === 'password' && !password.trim();
const keyMissing = authType === 'privateKey' && !privateKey.trim();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (passwordMissing || keyMissing) return;
onConfirm({
name: name || `${username}@${host}`,
host, port: parseInt(port, 10) || 22, username, authType,
groupId,
password: authType === 'password' ? password : undefined,
privateKey: authType === 'privateKey' ? privateKey : undefined,
});
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg shadow-xl w-[420px]">
<div className="px-4 py-3 border-b border-gray-700 text-sm font-medium flex items-center gap-2">
<Server size={14} /> {title}
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-3">
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="自动生成"
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<select
value={groupId ?? ''}
onChange={(e) => setGroupId(e.target.value || null)}
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
>
<option value="">()</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="text" value={host} onChange={(e) => setHost(e.target.value)} placeholder="192.168.1.1" required
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="number" value={port} onChange={(e) => setPort(e.target.value)}
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="root" required
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<div className="flex gap-2">
<button type="button" onClick={() => setAuthType('password')} className={`px-3 py-1 rounded text-xs ${authType === 'password' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'}`}></button>
<button type="button" onClick={() => setAuthType('privateKey')} className={`px-3 py-1 rounded text-xs ${authType === 'privateKey' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'}`}></button>
</div>
</div>
{authType === 'password' ? (
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
className={`w-full px-3 py-1.5 bg-gray-900 border rounded text-sm text-gray-200 focus:outline-none ${
passwordMissing ? 'border-red-500' : 'border-gray-700 focus:border-blue-500'
}`} />
{passwordMissing && (
<div className="text-xs text-red-400 mt-1"></div>
)}
</div>
) : (
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<textarea value={privateKey} onChange={(e) => setPrivateKey(e.target.value)} rows={4} placeholder="-----BEGIN RSA PRIVATE KEY-----"
className={`w-full px-3 py-1.5 bg-gray-900 border rounded text-sm text-gray-200 font-mono focus:outline-none resize-none ${
keyMissing ? 'border-red-500' : 'border-gray-700 focus:border-blue-500'
}`} />
{keyMissing && (
<div className="text-xs text-red-400 mt-1"></div>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"></button>
<button
type="submit"
disabled={passwordMissing || keyMissing}
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEdit ? '保存' : '保存'}
</button>
</div>
</form>
</div>
</div>
);
}
// ============================================
// 通用输入对话框
// ============================================
function PromptDialog({
title, placeholder, initialValue, onConfirm, onCancel,
}: {
title: string;
placeholder?: string;
initialValue?: string;
onConfirm: (v: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(initialValue ?? '');
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg shadow-xl w-[320px]">
<div className="px-4 py-3 border-b border-gray-700 text-sm font-medium flex items-center gap-2">
<FolderPlus size={14} /> {title}
</div>
<form onSubmit={(e) => { e.preventDefault(); if (value.trim()) onConfirm(value.trim()); }}>
<div className="p-4">
<input
autoFocus
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="w-full px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-2 px-4 pb-4">
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"></button>
<button type="submit" className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"></button>
</div>
</form>
</div>
</div>
);
}
// 防止 lint 报错
void Terminal;
void Edit3;
@@ -0,0 +1,183 @@
import { useEffect, useRef } from 'react';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { useSessionStore } from '../../stores/sessionStore';
import type { SessionStatus } from '../../../shared/types/session';
const api = window.electronAPI;
export default function TerminalPanel() {
const { sessions, activeSessionId, sessionStatuses } = useSessionStore();
const termRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const currentSessionRef = useRef<string | null>(null);
const activeSession = sessions.find((s) => s.id === activeSessionId);
// 永远不让 status 是 undefinedfallback 到 disconnected),保证 useEffect 能正确分支
const status: SessionStatus | undefined = activeSessionId
? (sessionStatuses[activeSessionId] || 'disconnected')
: undefined;
// 调试日志:帮助用户/开发者追踪状态变化
console.log(`[TerminalPanel] 渲染: activeSessionId=${activeSessionId}, status=${status}, sessionStatuses=`, sessionStatuses);
// 终端实例:仅在首次挂载时创建 xterm(termRef 永远在 DOM 上,useEffect 一定能跑)
useEffect(() => {
if (!termRef.current) return;
const xterm = new XTerm({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", Menlo, Monaco, monospace',
theme: {
background: '#1a1b26', foreground: '#c0caf5', cursor: '#c0caf5',
selectionBackground: '#33467c',
black: '#15161e', red: '#f7768e', green: '#9ece6a', yellow: '#e0af68',
blue: '#7aa2f7', magenta: '#bb9af7', cyan: '#7dcfff', white: '#a9b1d6',
brightBlack: '#414868', brightRed: '#f7768e', brightGreen: '#9ece6a',
brightYellow: '#e0af68', brightBlue: '#7aa2f7', brightMagenta: '#bb9af7',
brightCyan: '#7dcfff', brightWhite: '#c0caf5',
},
// 防止 SSH shell 的 \r\n 在 xterm 上叠字
convertEol: true,
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.loadAddon(new WebLinksAddon());
xterm.open(termRef.current);
xtermRef.current = xterm;
fitRef.current = fitAddon;
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
// 键盘输入 -> 主进程
xterm.onData((data) => {
if (currentSessionRef.current) {
api.terminalInput(currentSessionRef.current, data);
}
});
// 终端大小变化 -> 主进程
xterm.onResize(({ cols, rows }) => {
if (currentSessionRef.current) {
api.terminalResize(currentSessionRef.current, cols, rows);
}
});
// 主进程 -> 终端输出
const unsubOutput = api.onTerminalOutput((sessionId, data) => {
if (sessionId === currentSessionRef.current) {
xterm.write(data);
}
});
// 连接成功事件(清屏 + 提示)
const unsubConnected = api.onTerminalConnected((sessionId) => {
if (sessionId === currentSessionRef.current) {
xterm.clear();
xterm.write('\x1b[32m\u2713 SSH 连接成功\x1b[0m\r\n');
}
});
// 断开事件(仅在终端末尾追加提示,不清屏)
const unsubDisconnected = api.onTerminalDisconnected((sessionId, reason) => {
if (sessionId === currentSessionRef.current) {
const msg = reason ? `: ${reason}` : '';
xterm.write(`\r\n\x1b[31m\u2717 连接断开${msg}\x1b[0m\r\n`);
}
});
const handleResize = () => { try { fitAddon.fit(); } catch {} };
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
unsubOutput();
unsubConnected();
unsubDisconnected();
xterm.dispose();
xtermRef.current = null;
fitRef.current = null;
};
}, []);
// 活跃会话/状态变化时:清屏 + 写入对应状态信息
// 注意:xterm 实例一旦创建就常驻;切换会话靠清屏+写新内容来"换页"
useEffect(() => {
currentSessionRef.current = activeSessionId ?? null;
const xterm = xtermRef.current;
if (!xterm) return;
// 清屏,开始新一页
xterm.clear();
if (!activeSessionId || status === 'disconnected') {
// 未连接/已断开:显示欢迎信息
xterm.writeln('\x1b[36m========================================\x1b[0m');
xterm.writeln('\x1b[36m 智能SSH终端 - AI 辅助运维\x1b[0m');
xterm.writeln('\x1b[36m========================================\x1b[0m');
xterm.writeln('');
xterm.writeln('\x1b[33m欢迎使用!\x1b[0m');
xterm.writeln(' 1. 左侧面板创建 SSH 连接');
xterm.writeln(' 2. 点击会话即可连接远程服务器');
xterm.writeln(' 3. 右键会话可编辑/删除');
xterm.writeln(' 4. 下方 AI 对话区与 AI 助手交互');
xterm.writeln('');
xterm.writeln('\x1b[90m 当前无连接会话,从左侧选择会话开始\x1b[0m');
} else if (status === 'connecting') {
xterm.writeln('');
xterm.writeln(`\x1b[33m\u26a1 正在连接 ${activeSession?.username}@${activeSession?.host}:${activeSession?.port} ...\x1b[0m`);
xterm.writeln('\x1b[90m 请稍候,最长等待 15 秒\x1b[0m');
} else if (status === 'connected') {
// 连接成功时由 onTerminalConnected 监听器负责清屏+提示
// 这里什么都不做,避免重复清屏
} else if (status === 'error') {
xterm.writeln('');
xterm.writeln(`\x1b[31m\u2717 连接失败: ${activeSession?.username}@${activeSession?.host}:${activeSession?.port}\x1b[0m`);
xterm.writeln('\x1b[90m 常见原因:\x1b[0m');
xterm.writeln(' - 网络不通 / 主机或端口错误');
xterm.writeln(' - 用户名或密码错误');
xterm.writeln(' - 防火墙或安全组限制');
xterm.writeln(' - 私钥格式不匹配');
xterm.writeln('');
xterm.writeln('\x1b[33m 右键会话 → 编辑 检查配置后再试\x1b[0m');
}
}, [activeSessionId, status, activeSession?.host, activeSession?.port, activeSession?.username]);
return (
<div className="h-full flex flex-col bg-gray-950 relative">
<div className="flex items-center h-8 bg-gray-900 border-b border-gray-800 px-2 gap-1 flex-shrink-0">
<span className="text-xs text-gray-500">
{activeSession ? activeSession.name : '未连接'}
</span>
</div>
<div ref={termRef} className="flex-1 min-h-0" />
{!activeSession && (
<div className="absolute inset-0 top-8 flex flex-col items-center justify-center text-center select-none bg-gray-950 pointer-events-none">
<svg
width="56" height="56" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
className="text-gray-600 mb-3"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<div className="text-sm text-gray-400"></div>
<div className="text-xs text-gray-600 mt-1"></div>
</div>
)}
{activeSession && (
<div className="absolute top-8 left-2 flex items-center gap-1.5 px-2 py-0.5 bg-gray-800/80 rounded text-xs text-gray-300 pointer-events-none">
<div className={`w-1.5 h-1.5 rounded-full ${
status === 'connected' ? 'bg-green-500' :
status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
status === 'error' ? 'bg-red-500' : 'bg-gray-500'
}`} />
{activeSession.name}
</div>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
/// <reference types="vite/client" />
interface Window {
electronAPI: {
// 会话管理
sessionList: () => Promise<import('../shared/types/session').SessionConfig[]>;
sessionCreate: (config: Omit<import('../shared/types/session').SessionConfig, 'id' | 'createdAt' | 'updatedAt' | 'order'>) => Promise<import('../shared/types/session').SessionConfig>;
sessionUpdate: (id: string, updates: Partial<import('../shared/types/session').SessionConfig>) => Promise<import('../shared/types/session').SessionConfig | undefined>;
sessionDelete: (id: string) => Promise<void>;
sessionConnect: (sessionId: string) => Promise<{ success: boolean }>;
sessionDisconnect: (sessionId: string) => Promise<void>;
// 终端
terminalInput: (sessionId: string, data: string) => void;
terminalResize: (sessionId: string, cols: number, rows: number) => void;
// AI
aiChat: (sessionId: string | null, message: string, modelConfigId: string) => void;
aiToolAction: (toolCallId: string, action: string, editedContent?: string) => void;
aiInterrupt: () => void;
// 模型配置
modelList: () => Promise<import('../shared/types/ai').ModelConfig[]>;
modelSave: (config: Omit<import('../shared/types/ai').ModelConfig, 'id' | 'createdAt' | 'updatedAt'>) => Promise<import('../shared/types/ai').ModelConfig>;
modelDelete: (id: string) => Promise<void>;
modelPresets: () => Promise<import('../shared/types/ai').ModelPreset[]>;
// 事件监听
onTerminalOutput: (callback: (sessionId: string, data: string) => void) => () => void;
onTerminalConnected: (callback: (sessionId: string) => void) => () => void;
onTerminalDisconnected: (callback: (sessionId: string, reason?: string) => void) => () => void;
onSessionStatus: (callback: (sessionId: string, status: string) => void) => () => void;
onAiChatStream: (callback: (chunk: { content?: string; done: boolean }) => void) => () => void;
onAiToolCall: (callback: (toolCall: {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
description: string;
riskLevel: 'low' | 'high';
}) => void) => () => void;
onAiToolResult: (callback: (result: {
toolCallId: string;
success: boolean;
output: string;
error?: string;
}) => void) => () => void;
onAiError: (callback: (error: { message: string }) => void) => () => void;
onEnvDetected: (callback: (sessionId: string, environment: import('../shared/types/session').EnvironmentInfo) => void) => () => void;
removeAllListeners: (channel: string) => void;
};
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Electron API 快捷访问
const api = window.electronAPI;
export { api };
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import type { ChatMessage, ToolCall, ModelConfig } from '../../shared/types/ai';
interface AiChatState {
messages: ChatMessage[];
isStreaming: boolean;
pendingToolCalls: Map<string, ToolCall>;
activeModelConfigId: string | null;
modelConfigs: ModelConfig[];
addMessage: (message: ChatMessage) => void;
updateLastAssistantMessage: (content: string) => void;
setStreaming: (streaming: boolean) => void;
addToolCall: (toolCall: ToolCall) => void;
updateToolCall: (id: string, updates: Partial<ToolCall>) => void;
removeToolCall: (id: string) => void;
setActiveModelConfigId: (id: string | null) => void;
setModelConfigs: (configs: ModelConfig[]) => void;
addModelConfig: (config: ModelConfig) => void;
removeModelConfig: (id: string) => void;
clearMessages: () => void;
}
export const useAiChatStore = create<AiChatState>((set) => ({
messages: [],
isStreaming: false,
pendingToolCalls: new Map(),
activeModelConfigId: null,
modelConfigs: [],
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
updateLastAssistantMessage: (delta) =>
set((state) => {
const messages = [...state.messages];
const lastIdx = messages.findLastIndex((m) => m.role === 'assistant');
if (lastIdx >= 0) {
messages[lastIdx] = {
...messages[lastIdx],
content: messages[lastIdx].content + delta,
};
}
return { messages };
}),
setStreaming: (streaming) => set({ isStreaming: streaming }),
addToolCall: (toolCall) =>
set((state) => {
const map = new Map(state.pendingToolCalls);
map.set(toolCall.id, toolCall);
return { pendingToolCalls: map };
}),
updateToolCall: (id, updates) =>
set((state) => {
const map = new Map(state.pendingToolCalls);
const existing = map.get(id);
if (existing) map.set(id, { ...existing, ...updates });
return { pendingToolCalls: map };
}),
removeToolCall: (id) =>
set((state) => {
const map = new Map(state.pendingToolCalls);
map.delete(id);
return { pendingToolCalls: map };
}),
setActiveModelConfigId: (id) => set({ activeModelConfigId: id }),
setModelConfigs: (configs) => set({ modelConfigs: configs }),
addModelConfig: (config) =>
set((state) => ({ modelConfigs: [...state.modelConfigs, config] })),
removeModelConfig: (id) =>
set((state) => ({
modelConfigs: state.modelConfigs.filter((c) => c.id !== id),
activeModelConfigId: state.activeModelConfigId === id ? null : state.activeModelConfigId,
})),
clearMessages: () => set({ messages: [], pendingToolCalls: new Map() }),
}));
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import type { SessionConfig, SessionGroup, SessionStatus } from '../../shared/types/session';
interface SessionState {
groups: SessionGroup[];
sessions: SessionConfig[];
sessionStatuses: Record<string, SessionStatus>;
activeSessionId: string | null;
setGroups: (groups: SessionGroup[]) => void;
addGroup: (group: SessionGroup) => void;
updateGroup: (id: string, updates: Partial<SessionGroup>) => void;
removeGroup: (id: string) => void;
setSessions: (sessions: SessionConfig[]) => void;
addSession: (session: SessionConfig) => void;
updateSession: (id: string, updates: Partial<SessionConfig>) => void;
removeSession: (id: string) => void;
setSessionStatus: (id: string, status: SessionStatus) => void;
setActiveSessionId: (id: string | null) => void;
}
export const useSessionStore = create<SessionState>((set) => ({
groups: [],
sessions: [],
sessionStatuses: {},
activeSessionId: null,
setGroups: (groups) => set({ groups }),
addGroup: (group) => set((state) => ({ groups: [...state.groups, group] })),
updateGroup: (id, updates) =>
set((state) => ({
groups: state.groups.map((g) => (g.id === id ? { ...g, ...updates } : g)),
})),
removeGroup: (id) =>
set((state) => {
// 递归删除子分组及其中会话
const toDelete = new Set<string>();
const collect = (gid: string) => {
toDelete.add(gid);
state.groups.filter((g) => g.parentId === gid).forEach((g) => collect(g.id));
};
collect(id);
return {
groups: state.groups.filter((g) => !toDelete.has(g.id)),
sessions: state.sessions.filter((s) => !s.groupId || !toDelete.has(s.groupId)),
sessionStatuses: Object.fromEntries(
Object.entries(state.sessionStatuses).filter(([k]) => !toDelete.has(k))
),
activeSessionId:
state.activeSessionId && toDelete.has(state.activeSessionId) ? null : state.activeSessionId,
};
}),
setSessions: (sessions) => set({ sessions }),
addSession: (session) =>
set((state) => ({
sessions: [...state.sessions, session],
sessionStatuses: { ...state.sessionStatuses, [session.id]: 'disconnected' },
})),
updateSession: (id, updates) =>
set((state) => ({
sessions: state.sessions.map((s) => (s.id === id ? { ...s, ...updates } : s)),
})),
removeSession: (id) =>
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== id),
sessionStatuses: Object.fromEntries(
Object.entries(state.sessionStatuses).filter(([k]) => k !== id)
),
activeSessionId: state.activeSessionId === id ? null : state.activeSessionId,
})),
setSessionStatus: (id, status) =>
set((state) => ({ sessionStatuses: { ...state.sessionStatuses, [id]: status } })),
setActiveSessionId: (id) => set({ activeSessionId: id }),
}));
+42
View File
@@ -0,0 +1,42 @@
@import '@xterm/xterm/css/xterm.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--sidebar-width: 48px;
--panel-min-width: 200px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
/* 防止 Electron 拖拽 */
body {
-webkit-user-select: none;
user-select: none;
}
input, textarea, .xterm {
-webkit-user-select: text;
user-select: text;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* xterm overrides */
.xterm { padding: 4px; }
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
terminal: {
bg: '#1a1b26',
fg: '#c0caf5',
},
},
},
},
plugins: [],
};
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,