Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88cc5b233a | |||
| a141fe7409 | |||
| 47540796f5 |
@@ -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 助手集成
|
||||
@@ -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);
|
||||
|
||||
Generated
+127
-127
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
],
|
||||
};
|
||||
@@ -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 是 undefined(fallback 到 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>
|
||||
);
|
||||
}
|
||||
Vendored
+52
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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() }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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; }
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
Reference in New Issue
Block a user