diff --git a/pack.js b/pack.js
index bf8d782..0c79e3f 100644
--- a/pack.js
+++ b/pack.js
@@ -1,12 +1,9 @@
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',
diff --git a/package-lock.json b/package-lock.json
index cd5fc61..9fe6d84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
}
},
diff --git a/package.json b/package.json
index 056dbb3..46591ec 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/renderer/index.html b/src/renderer/index.html
new file mode 100644
index 0000000..664f00d
--- /dev/null
+++ b/src/renderer/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 智能SSH终端
+
+
+
+
+
+
diff --git a/src/renderer/postcss.config.js b/src/renderer/postcss.config.js
new file mode 100644
index 0000000..96ecf53
--- /dev/null
+++ b/src/renderer/postcss.config.js
@@ -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(),
+ ],
+};
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
new file mode 100644
index 0000000..885533e
--- /dev/null
+++ b/src/renderer/src/App.tsx
@@ -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 = {
+ sessions: '会话列表',
+ tunnels: '隧道管理',
+ mcp: 'MCP',
+ community: '社区',
+ models: 'AI 模型',
+ settings: '设置',
+};
+
+const AI_PANEL_WIDTH = 380;
+
+export default function App() {
+ const [activeTab, setActiveTab] = useState('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 ;
+ case 'models':
+ return ;
+ case 'tunnels':
+ return 隧道管理(开发中)
;
+ case 'mcp':
+ return MCP(开发中)
;
+ case 'community':
+ return 社区(开发中)
;
+ case 'settings':
+ return 设置(开发中)
;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {/* 自定义标题栏 */}
+
+
+ {/* 主内容区 */}
+
+ {/* ① 功能导航(固定宽度,永不收缩) */}
+
setAiPanelVisible(v => !v)}
+ />
+
+ {/* ② 主工作区:管理面板 + 终端(占据剩余空间) */}
+
+
+ {/* 管理面板 */}
+
+
+
+
+ {TAB_TITLES[activeTab]}
+
+
+ {renderPanel()}
+
+
+
+
+
+ {/* 命令终端 */}
+
+
+
+
+ {/* ③ AI对话区(独立侧栏,可收起) */}
+ {aiPanelVisible && (
+
+
setAiPanelVisible(false)} />
+
+ )}
+
+
+ );
+}
diff --git a/src/renderer/src/components/ai/AiChatPanel.tsx b/src/renderer/src/components/ai/AiChatPanel.tsx
new file mode 100644
index 0000000..56b7081
--- /dev/null
+++ b/src/renderer/src/components/ai/AiChatPanel.tsx
@@ -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(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 (
+
+ {/* 头部 */}
+
+
+
AI 助手
+
+ {isStreaming && (
+
+ )}
+ {messages.length > 0 && (
+
+ )}
+ {onClose && (
+
+ )}
+
+
+ {/* 消息区域 - 可滚动 */}
+
+ {messages.length === 0 && (
+
+
+
+
配置模型后即可与 AI 助手对话
+
支持自然语言描述运维任务
+
+
+ )}
+ {messages.map((msg) => (
+
+ {msg.role === 'assistant' && (
+
+
+
+ )}
+
+ {msg.role === 'assistant' && msg.content === '' && isStreaming ? (
+
思考中...
+ ) : (
+
{msg.content}
+ )}
+
+ {msg.role === 'user' && (
+
+
+
+ )}
+
+ ))}
+ {Array.from(pendingToolCalls.values()).map((tc) => (
+
+ ))}
+
+
+
+ {/* 输入区域 - 固定在底部 */}
+
+
+ );
+}
diff --git a/src/renderer/src/components/ai/ModelSettings.tsx b/src/renderer/src/components/ai/ModelSettings.tsx
new file mode 100644
index 0000000..cf4d0e9
--- /dev/null
+++ b/src/renderer/src/components/ai/ModelSettings.tsx
@@ -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([]);
+ 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) => {
+ 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 (
+
+
+
{modelConfigs.length} 个模型
+
+
+
+ {/* 已配置的模型 */}
+
+ {modelConfigs.map((mc) => (
+
+
+
+
{mc.name}
+
{mc.model}
+
+
+
+
+ ))}
+
+
+ {/* 快速添加预设 */}
+ {presets.length > 0 && (
+ <>
+
快速添加预设
+
+ {presets.map((p) => (
+
+ ))}
+
+ >
+ )}
+
+ {/* 自定义添加 */}
+ {showAdd && (
+
setShowAdd(false)}
+ />
+ )}
+
+ );
+}
+
+function CustomModelDialog({ onConfirm, onCancel }: { onConfirm: (c: Omit) => void; onCancel: () => void }) {
+ const [name, setName] = useState('');
+ const [provider, setProvider] = useState('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 (
+
+ );
+}
diff --git a/src/renderer/src/components/ai/ToolCallCard.tsx b/src/renderer/src/components/ai/ToolCallCard.tsx
new file mode 100644
index 0000000..59c08eb
--- /dev/null
+++ b/src/renderer/src/components/ai/ToolCallCard.tsx
@@ -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 (
+
+ {/* 标题 */}
+
+
+
+ {isCommand ? '执行命令' : isFileEdit ? '编辑文件' : toolCall.name}
+
+ {isExecuting && 执行中...}
+ {toolCall.status === 'completed' && 已完成}
+ {toolCall.status === 'error' && 失败}
+
+
+ {/* 命令/文件内容 */}
+ {isCommand && (
+
+ {command}
+
+ )}
+ {isFileEdit && (
+
+ 文件: {filePath}
+
+ )}
+
+ {/* 操作按钮 - 仅 pending 状态显示 */}
+ {toolCall.status === 'pending' && (
+
+ {isCommand ? (
+ <>
+
+
+
+
+ >
+ ) : isFileEdit ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
+ )}
+
+ {/* 执行结果 */}
+ {toolCall.result && isDone && (
+
+
{toolCall.result.output}
+ {toolCall.result.error &&
错误: {toolCall.result.error}
}
+
+ )}
+
+ );
+}
diff --git a/src/renderer/src/components/layout/ResizablePanel.tsx b/src/renderer/src/components/layout/ResizablePanel.tsx
new file mode 100644
index 0000000..926397b
--- /dev/null
+++ b/src/renderer/src/components/layout/ResizablePanel.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/src/renderer/src/components/layout/Sidebar.tsx b/src/renderer/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..064b05e
--- /dev/null
+++ b/src/renderer/src/components/layout/Sidebar.tsx
@@ -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 (
+
+ {items.map((item) => {
+ const Icon = item.icon;
+ const isActive = activeTab === item.id;
+ return (
+
+ );
+ })}
+
+ {/* 底部 AI 助手开关 */}
+
+
+
+
+ );
+}
diff --git a/src/renderer/src/components/layout/TitleBar.tsx b/src/renderer/src/components/layout/TitleBar.tsx
new file mode 100644
index 0000000..c0c62c9
--- /dev/null
+++ b/src/renderer/src/components/layout/TitleBar.tsx
@@ -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 (
+
+
+ 智能SSH终端
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/src/components/session/SessionList.tsx b/src/renderer/src/components/session/SessionList.tsx
new file mode 100644
index 0000000..4e97029
--- /dev/null
+++ b/src/renderer/src/components/session/SessionList.tsx
@@ -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(null);
+ const [showCreateGroup, setShowCreateGroup] = useState(false);
+ const [editingGroup, setEditingGroup] = useState(null);
+ const [editingSession, setEditingSession] = useState(null);
+ const [createSessionGroupId, setCreateSessionGroupId] = useState(null);
+
+ // ---- 会话操作 ----
+ const handleCreateSession = async (
+ config: Omit
+ ) => {
+ 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>
+ ) => {
+ 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 (
+
+
+
{sessions.length} 个会话
+
+
+
+
+
+
+
+ {groups.length === 0 && sessions.filter((s) => !s.groupId).length === 0 ? (
+
+ 点击右上角 + 新建会话
+
+ 或点击文件夹图标新建分组
+
+ 右键会话/分组可编辑或删除
+
+ ) : (
+
{
+ setCreateGroupParentId(parentId);
+ setShowCreateGroup(true);
+ }}
+ onCreateSessionHere={openCreateSessionInGroup}
+ />
+ )}
+
+
+ {showCreateSession && (
+
{ setShowCreateSession(false); setCreateSessionGroupId(null); }}
+ />
+ )}
+ {editingSession && (
+ handleUpdateSession(editingSession.id, config)}
+ onCancel={() => setEditingSession(null)}
+ />
+ )}
+ {showCreateGroup && (
+ handleCreateGroup(name, createGroupParentId)}
+ onCancel={() => { setShowCreateGroup(false); setCreateGroupParentId(null); }}
+ />
+ )}
+ {editingGroup && (
+ handleRenameGroup(editingGroup.id, name)}
+ onCancel={() => setEditingGroup(null)}
+ />
+ )}
+
+ );
+}
+
+// ============================================
+// 树形渲染
+// ============================================
+
+interface TreeListProps {
+ groups: SessionGroup[];
+ sessions: SessionConfig[];
+ sessionStatuses: Record;
+ 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 (
+
+ {rootGroups.map((g) => (
+
+ ))}
+ {rootSessions.map((s) => (
+
+ ))}
+
+ );
+}
+
+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(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 (
+ <>
+ handlers.onToggleCollapse(group.id, !group.collapsed)}
+ onContextMenu={openContextMenu}
+ >
+ {group.collapsed ?
+ :
}
+ {group.collapsed
+ ?
+ :
}
+
{group.name}
+
e.stopPropagation()}>
+
+ {menuOpen && (
+
setMenuOpen(false)}
+ items={[
+ { icon: , label: '新建会话', onClick: () => handlers.onCreateSessionHere(group.id) },
+ { icon: , label: '新建子分组', onClick: () => handlers.onCreateGroupHere(group.id) },
+ { icon: , label: '重命名', onClick: () => handlers.onEditGroup(group) },
+ { divider: true },
+ { icon: , label: '删除分组', danger: true, onClick: () => handlers.onDeleteGroup(group.id) },
+ ]}
+ />
+ )}
+
+
+ {!group.collapsed && (
+ <>
+ {childGroups.map((g) => (
+
+ ))}
+ {childSessions.map((s) => (
+
+ ))}
+ >
+ )}
+ >
+ );
+}
+
+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(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: , label: '断开', onClick: () => handlers.onDisconnect(session.id) }
+ : { icon: , label: '连接', onClick: () => handlers.onConnect(session) },
+ { icon: , label: '编辑', onClick: () => handlers.onEditSession(session) },
+ { divider: true },
+ { icon: , label: '删除', danger: true, onClick: () => handlers.onDeleteSession(session.id) },
+ ];
+
+ return (
+ {
+ if (status === 'disconnected' || status === 'error') handlers.onConnect(session);
+ else if (status === 'connected') handlers.onDisconnect(session.id);
+ }}
+ onContextMenu={openContextMenu}
+ >
+
+
+
{session.name}
+
e.stopPropagation()}>
+
+ {menuOpen && (
+ setMenuOpen(false)}
+ items={items}
+ />
+ )}
+
+
+ );
+}
+
+// ============================================
+// 通用右键菜单
+// ============================================
+
+interface ContextMenuItem {
+ icon?: React.ReactNode;
+ label?: string;
+ onClick?: () => void;
+ divider?: boolean;
+ danger?: boolean;
+}
+
+function ContextMenu({
+ menuRef, pos, onClose, items,
+}: {
+ menuRef: React.RefObject;
+ pos: { x: number; y: number } | null;
+ onClose: () => void;
+ items: ContextMenuItem[];
+}) {
+ // 根据 pos 决定是相对定位(按按钮)还是固定定位(按鼠标)
+ // 右键触发时 pos 非空,使用 fixed;点击 ... 按钮时 pos 为 null,使用 absolute
+ if (pos) {
+ return (
+
+ {items.map((item, i) =>
+ item.divider ? (
+
+ ) : (
+
+ )
+ )}
+
+ );
+ }
+ return (
+
+ {items.map((item, i) =>
+ item.divider ? (
+
+ ) : (
+
+ )
+ )}
+
+ );
+}
+
+// ============================================
+// 创建/编辑会话对话框
+// ============================================
+
+function CreateSessionDialog({
+ groups, defaultGroupId, editingSession, onConfirm, onCancel,
+}: {
+ groups: SessionGroup[];
+ defaultGroupId: string | null;
+ editingSession?: SessionConfig | null;
+ onConfirm: (c: Omit) => 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(editingSession?.authType ?? 'password');
+ const [password, setPassword] = useState(editingSession?.password ?? '');
+ const [privateKey, setPrivateKey] = useState(editingSession?.privateKey ?? '');
+ const [groupId, setGroupId] = useState(
+ 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 (
+
+ );
+}
+
+// ============================================
+// 通用输入对话框
+// ============================================
+
+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 (
+
+
+
+ {title}
+
+
+
+
+ );
+}
+
+// 防止 lint 报错
+void Terminal;
+void Edit3;
diff --git a/src/renderer/src/components/terminal/TerminalPanel.tsx b/src/renderer/src/components/terminal/TerminalPanel.tsx
new file mode 100644
index 0000000..384f333
--- /dev/null
+++ b/src/renderer/src/components/terminal/TerminalPanel.tsx
@@ -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(null);
+ const xtermRef = useRef(null);
+ const fitRef = useRef(null);
+ const currentSessionRef = useRef(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 (
+
+
+
+ {activeSession ? activeSession.name : '未连接'}
+
+
+
+ {!activeSession && (
+
+
+
请连接一个会话
+
从左侧会话列表中选择一个会话开始连接
+
+ )}
+ {activeSession && (
+
+
+ {activeSession.name}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/renderer/src/global.d.ts b/src/renderer/src/global.d.ts
new file mode 100644
index 0000000..48f525f
--- /dev/null
+++ b/src/renderer/src/global.d.ts
@@ -0,0 +1,52 @@
+///
+
+interface Window {
+ electronAPI: {
+ // 会话管理
+ sessionList: () => Promise;
+ sessionCreate: (config: Omit) => Promise;
+ sessionUpdate: (id: string, updates: Partial) => Promise;
+ sessionDelete: (id: string) => Promise;
+ sessionConnect: (sessionId: string) => Promise<{ success: boolean }>;
+ sessionDisconnect: (sessionId: string) => Promise;
+
+ // 终端
+ 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;
+ modelSave: (config: Omit) => Promise;
+ modelDelete: (id: string) => Promise;
+ modelPresets: () => Promise;
+
+ // 事件监听
+ 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;
+ 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;
+ };
+}
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx
new file mode 100644
index 0000000..da0469b
--- /dev/null
+++ b/src/renderer/src/main.tsx
@@ -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(
+
+
+
+);
+
+// Electron API 快捷访问
+const api = window.electronAPI;
+export { api };
diff --git a/src/renderer/src/stores/aiChatStore.ts b/src/renderer/src/stores/aiChatStore.ts
new file mode 100644
index 0000000..af5989c
--- /dev/null
+++ b/src/renderer/src/stores/aiChatStore.ts
@@ -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;
+ 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) => 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((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() }),
+}));
diff --git a/src/renderer/src/stores/sessionStore.ts b/src/renderer/src/stores/sessionStore.ts
new file mode 100644
index 0000000..1d7dce2
--- /dev/null
+++ b/src/renderer/src/stores/sessionStore.ts
@@ -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;
+ activeSessionId: string | null;
+
+ setGroups: (groups: SessionGroup[]) => void;
+ addGroup: (group: SessionGroup) => void;
+ updateGroup: (id: string, updates: Partial) => void;
+ removeGroup: (id: string) => void;
+
+ setSessions: (sessions: SessionConfig[]) => void;
+ addSession: (session: SessionConfig) => void;
+ updateSession: (id: string, updates: Partial) => void;
+ removeSession: (id: string) => void;
+ setSessionStatus: (id: string, status: SessionStatus) => void;
+ setActiveSessionId: (id: string | null) => void;
+}
+
+export const useSessionStore = create((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();
+ 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 }),
+}));
diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css
new file mode 100644
index 0000000..250f3d1
--- /dev/null
+++ b/src/renderer/src/styles/index.css
@@ -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; }
diff --git a/src/renderer/tailwind.config.js b/src/renderer/tailwind.config.js
new file mode 100644
index 0000000..d1647f2
--- /dev/null
+++ b/src/renderer/tailwind.config.js
@@ -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: [],
+};
diff --git a/tsconfig.main.json b/tsconfig.main.json
index ee3f820..61cdd2a 100644
--- a/tsconfig.main.json
+++ b/tsconfig.main.json
@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
- "strict": true,
+ "strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,