feat: 初始化 SSH AI Agent 桌面客户端 v0.1.0
- Electron + React + TypeScript 桌面应用 - SSH 连接管理(ssh2) - AI Agent ReAct 循环(OpenAI 兼容 API) - 5 个内置工具(命令执行、文件读写、目录列表、系统信息) - 6 个模型预设(GPT-4o、DeepSeek、通义千问、豆包、Ollama、LM Studio) - 自定义模型支持 - 高风险工具审批流 - 无边框窗口 + 自定义标题栏 - AI 对话区右侧布局 - xterm.js 终端仿真 - 配置持久化(electron-store)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
renderer/
|
||||
release/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,44 @@
|
||||
schema_version: 1
|
||||
module_path: ""
|
||||
title: SSH AI Agent 桌面客户端
|
||||
scope:
|
||||
- .gitignore
|
||||
- package.json
|
||||
- tsconfig.main.json
|
||||
- tsconfig.renderer.json
|
||||
- vite.config.ts
|
||||
- .assis.json
|
||||
- src\main\handlers\aiHandler.ts
|
||||
- src\main\handlers\modelHandler.ts
|
||||
- src\main\handlers\sessionHandler.ts
|
||||
- src\main\handlers\terminalHandler.ts
|
||||
- src\main\index.ts
|
||||
- src\main\preload.ts
|
||||
- src\main\services\ai\modelProvider.ts
|
||||
- src\main\services\ai\toolRegistry.ts
|
||||
- src\main\services\ssh\sshManager.ts
|
||||
- src\main\services\storage\modelStore.ts
|
||||
- src\main\services\storage\sessionStore.ts
|
||||
- src\renderer\index.html
|
||||
- src\renderer\postcss.config.js
|
||||
- src\renderer\src\App.tsx
|
||||
- src\renderer\src\components\ai\AiChatPanel.tsx
|
||||
- src\renderer\src\components\ai\ModelSettings.tsx
|
||||
- src\renderer\src\components\ai\ToolCallCard.tsx
|
||||
- src\renderer\src\components\layout\ResizablePanel.tsx
|
||||
- src\renderer\src\components\layout\Sidebar.tsx
|
||||
- src\renderer\src\components\session\SessionList.tsx
|
||||
- src\renderer\src\components\terminal\TerminalPanel.tsx
|
||||
- src\renderer\src\global.d.ts
|
||||
- src\renderer\src\main.tsx
|
||||
- src\renderer\src\stores\aiChatStore.ts
|
||||
- src\renderer\src\stores\sessionStore.ts
|
||||
- src\renderer\src\styles\index.css
|
||||
- src\renderer\tailwind.config.js
|
||||
- src\shared\types\ai.ts
|
||||
- src\shared\types\index.ts
|
||||
- src\shared\types\ipc.ts
|
||||
- src\shared\types\session.ts
|
||||
source_files: []
|
||||
depends_on: []
|
||||
related_to: []
|
||||
@@ -0,0 +1,3 @@
|
||||
- 核心框架:Electron 30 + React 18 + TypeScript。
|
||||
- 构建工具:Vite (渲染进程) + tsc (主进程)。
|
||||
- 关键依赖:`ssh2` (SSH 协议实现), `openai` (LLM 接口), `zustand` (状态管理), `@xterm/xterm` (终端模拟)。
|
||||
@@ -0,0 +1,4 @@
|
||||
- 采用 Electron 多进程架构,通过 `src/main/index.ts` 启动主进程并注册 IPC 处理器,`src/renderer` 负责 React 界面渲染。
|
||||
- 通信层基于 `src/shared/types/ipc.ts` 定义的通道规范,利用 `contextBridge` 在预加载脚本中安全暴露 API。
|
||||
- 业务逻辑分层清晰:主进程 `handlers` 处理 IPC 请求,`services` 封装 SSH 连接 (`sshManager`)、AI 模型交互 (`modelProvider`) 及持久化存储。
|
||||
- 渲染端采用 Zustand 进行全局状态管理,通过自定义 Hooks 订阅会话状态与 AI 对话流,实现终端输出与聊天界面的实时同步。
|
||||
@@ -0,0 +1 @@
|
||||
集成 SSH 终端与 AI 助手的桌面应用,支持远程服务器管理、交互式终端及基于 LLM 的智能运维操作。
|
||||
@@ -0,0 +1,2 @@
|
||||
- 开发启动:`npm run dev:watch` 使用 `concurrently` 并行执行 TypeScript 编译监听与 Electron 启动。
|
||||
- 打包发布:`npm run dist` 依次执行主进程/渲染进程构建,并调用 `electron-builder` 生成 NSIS 安装包。
|
||||
@@ -0,0 +1,4 @@
|
||||
- IPC 通信遵循严格的类型契约,在 `shared/types/ipc.ts` 中统一定义通道名与载荷类型,确保主渲染进程数据一致性。
|
||||
- 状态管理采用轻量级模式,渲染端使用 `zustand` 创建独立 Store (如 `sessionStore`, `aiChatStore`) 隔离不同业务域的状态。
|
||||
- 服务层采用单例导出模式 (如 `sshManager`, `modelStore`),在主进程模块间共享有状态的服务实例。
|
||||
- AI 交互实现 ReAct 循环模式,在 `aiHandler.ts` 中通过流式响应与工具调用审批机制 (`waitForApproval`) 处理复杂的智能体决策流程。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 知识卡导出索引文件
|
||||
schema_version: 1
|
||||
locale: zh-CN
|
||||
branch: Q0DeR-MaG1C-BrAnCh-Fo2-NoN-GiT-Or-No-C0MmIt
|
||||
exported_at: "2026-06-11T10:30:36Z"
|
||||
modules:
|
||||
"":
|
||||
dir_name: SSH AI Agent 桌面客户端
|
||||
title: SSH AI Agent 桌面客户端
|
||||
scope:
|
||||
- .gitignore
|
||||
- package.json
|
||||
- tsconfig.main.json
|
||||
- tsconfig.renderer.json
|
||||
- vite.config.ts
|
||||
- .assis.json
|
||||
- src\main\handlers\aiHandler.ts
|
||||
- src\main\handlers\modelHandler.ts
|
||||
- src\main\handlers\sessionHandler.ts
|
||||
- src\main\handlers\terminalHandler.ts
|
||||
- src\main\index.ts
|
||||
- src\main\preload.ts
|
||||
- src\main\services\ai\modelProvider.ts
|
||||
- src\main\services\ai\toolRegistry.ts
|
||||
- src\main\services\ssh\sshManager.ts
|
||||
- src\main\services\storage\modelStore.ts
|
||||
- src\main\services\storage\sessionStore.ts
|
||||
- src\renderer\index.html
|
||||
- src\renderer\postcss.config.js
|
||||
- src\renderer\src\App.tsx
|
||||
- src\renderer\src\components\ai\AiChatPanel.tsx
|
||||
- src\renderer\src\components\ai\ModelSettings.tsx
|
||||
- src\renderer\src\components\ai\ToolCallCard.tsx
|
||||
- src\renderer\src\components\layout\ResizablePanel.tsx
|
||||
- src\renderer\src\components\layout\Sidebar.tsx
|
||||
- src\renderer\src\components\session\SessionList.tsx
|
||||
- src\renderer\src\components\terminal\TerminalPanel.tsx
|
||||
- src\renderer\src\global.d.ts
|
||||
- src\renderer\src\main.tsx
|
||||
- src\renderer\src\stores\aiChatStore.ts
|
||||
- src\renderer\src\stores\sessionStore.ts
|
||||
- src\renderer\src\styles\index.css
|
||||
- src\renderer\tailwind.config.js
|
||||
- src\shared\types\ai.ts
|
||||
- src\shared\types\index.ts
|
||||
- src\shared\types\ipc.ts
|
||||
- src\shared\types\session.ts
|
||||
source_files: []
|
||||
children: []
|
||||
depends_on: []
|
||||
related_to: []
|
||||
@@ -0,0 +1,9 @@
|
||||
[19736:0612/113223.282:ERROR:cache_util_win.cc(20)] Unable to move the cache: 拒绝访问。 (0x5)
|
||||
[19736:0612/113223.284:ERROR:cache_util_win.cc(20)] Unable to move the cache: 拒绝访问。 (0x5)
|
||||
[19736:0612/113223.284:ERROR:cache_util_win.cc(20)] Unable to move the cache: 拒绝访问。 (0x5)
|
||||
[19736:0612/113223.289:ERROR:disk_cache.cc(208)] Unable to create cache
|
||||
[19736:0612/113223.294:ERROR:gpu_disk_cache.cc(711)] Gpu Cache Creation failed: -2
|
||||
[19736:0612/113223.296:ERROR:disk_cache.cc(208)] Unable to create cache
|
||||
[19736:0612/113223.297:ERROR:gpu_disk_cache.cc(711)] Gpu Cache Creation failed: -2
|
||||
[19736:0612/113223.299:ERROR:disk_cache.cc(208)] Unable to create cache
|
||||
[19736:0612/113223.301:ERROR:gpu_disk_cache.cc(711)] Gpu Cache Creation failed: -2
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
> ssh-ai-agent@0.1.0 dist
|
||||
> npm run build && electron-builder --win --x64 --config.npmRebuild=false
|
||||
|
||||
|
||||
> ssh-ai-agent@0.1.0 build
|
||||
> npm run build:main && npm run build:renderer
|
||||
|
||||
|
||||
> ssh-ai-agent@0.1.0 build:main
|
||||
> tsc -p tsconfig.main.json
|
||||
|
||||
|
||||
> ssh-ai-agent@0.1.0 build:renderer
|
||||
> vite build
|
||||
|
||||
npm.cmd : [33mThe CJS build of Vite's Node API is deprecated. See https://vite
|
||||
.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
|
||||
[39m
|
||||
所在位置 行:1 字符: 163
|
||||
+ ... = "false"; & 'C:\Program Files\nodejs\npm.cmd' run dist -- --win --x ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: ([33mThe CJS bu...e details.[39m
|
||||
:String) [], RemoteException
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
[36mvite v5.4.21 [32mbuilding for production...[36m[39m
|
||||
transforming...
|
||||
(node:27320) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///D:/
|
||||
test_project/src/renderer/postcss.config.js is not specified and it doesn't par
|
||||
se as CommonJS.
|
||||
Reparsing as ES module because module syntax was detected. This incurs a perfor
|
||||
mance overhead.
|
||||
To eliminate this warning, add "type": "module" to \\?\D:\test_project\package.
|
||||
json.
|
||||
(Use `node --trace-warnings ...` to show where the warning was created)
|
||||
[32m鉁?[39m 1528 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
[2m../../renderer/[22m[32mindex.html [39m[1m[2m 0.44 kB[22m[1m[22m[2m 鈹?gzip: 0.33 kB[22m
|
||||
[2m../../renderer/[22m[35massets/index-qN8e0yep.css [39m[1m[2m 18.94 kB[22m[1m[22m[2m 鈹?gzip: 5.26 kB[22m
|
||||
[2m../../renderer/[22m[36massets/index-2IgjmCyK.js [39m[1m[2m487.67 kB[22m[1m[22m[2m 鈹?gzip: 133.95 kB[22m
|
||||
[32m鉁?built in 8.70s[39m
|
||||
鈥?electron-builder version=24.13.3 os=10.0.19045
|
||||
鈥?loaded configuration file=package.json ("build" field)
|
||||
鈥?skipped dependencies rebuild reason=npmRebuild is set to false
|
||||
鈥?packaging platform=win32 arch=x64 electron=30.5.1 appOutDir=release\win-unpacked
|
||||
鈥?rcedit skipped (sandbox mode) file=鏅鸿兘SSH缁堢.exe
|
||||
鈥?building target=nsis file=release\鏅鸿兘SSH缁堢-0.1.0-x64.exe archs=x64 oneClick=false perMachine=false
|
||||
鈥?building block map blockMapFile=release\鏅鸿兘SSH缁堢-0.1.0-x64.exe.blockmap
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+13
@@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
<script type="module" crossorigin src="./assets/index-CnYmvNcz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DI4KIlif.css">
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100">
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
@@ -0,0 +1,19 @@
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// 删除所有代码签名环境变量
|
||||
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!');
|
||||
} catch (e) {
|
||||
console.log('Failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Generated
+7619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "ssh-ai-agent",
|
||||
"version": "0.1.0",
|
||||
"description": "智能SSH终端 - 集成 AI 助手的远程运维桌面客户端",
|
||||
"author": {
|
||||
"name": "SSH AI Agent Team"
|
||||
},
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.main.json && electron .",
|
||||
"dev:watch": "concurrently \"tsc -p tsconfig.main.json -w\" \"wait-on dist/main/index.js && electron .\"",
|
||||
"build:renderer": "vite build",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"test:patch": "node test_patch.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"electron-store": "^8.2.0",
|
||||
"lucide-react": "^0.383.0",
|
||||
"openai": "^4.52.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"ssh2": "^1.15.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/ssh2": "^1.15.4",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^30.0.8",
|
||||
"electron-builder": "^24.13.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.ssh-ai-agent.app",
|
||||
"productName": "智能SSH终端",
|
||||
"copyright": "Copyright © 2026",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"renderer/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "resources/icon.ico",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"forceCodeSigning": false,
|
||||
"sign": null,
|
||||
"signingHashAlgorithms": []
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "智能SSH终端",
|
||||
"installerLanguages": [
|
||||
"zh_CN"
|
||||
],
|
||||
"language": "2052",
|
||||
"installerIcon": "resources/icon.ico",
|
||||
"uninstallerIcon": "resources/icon.ico",
|
||||
"uninstallDisplayName": "卸载 智能SSH终端",
|
||||
"deleteAppDataOnUninstall": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[2026-06-11T12:19:49.325Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-11T12:21:37.477Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
[2026-06-11T12:25:11.796Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-11T12:26:46.516Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
[2026-06-12T01:52:22.859Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-12T01:53:51.787Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
[2026-06-12T02:19:07.095Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-12T02:20:45.257Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
[2026-06-12T03:30:37.762Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-12T03:31:10.415Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
[2026-06-12T04:57:25.001Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-12T04:58:04.088Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
[2026-06-12T05:10:08.225Z] PATCH_ENTRY name=nsis-3.0.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-3.0.4.1/nsis-3.0.4.1.7z
|
||||
[2026-06-12T05:10:46.764Z] PATCH_ENTRY name=nsis-resources-3.4.1 url=https://github.com/electron-userland/electron-builder-binaries/releases/download/nsis-resources-3.4.1/nsis-resources-3.4.1.7z
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,347 @@
|
||||
import type { BrowserWindow, IpcMainEvent } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
import OpenAI from 'openai';
|
||||
import { IPC } from '../../shared/types/ipc';
|
||||
import type { ModelConfig } from '../../shared/types/ai';
|
||||
import type { EnvironmentInfo, SessionConfig } from '../../shared/types/session';
|
||||
import { ModelProvider } from '../services/ai/modelProvider';
|
||||
import { getTool, getToolDefinitions, type ToolContext } from '../services/ai/toolRegistry';
|
||||
import { modelStore } from '../services/storage/modelStore';
|
||||
import { sessionStore } from '../services/storage/sessionStore';
|
||||
import { sshManager } from '../services/ssh/sshManager';
|
||||
|
||||
// 系统提示词模板
|
||||
function buildSystemPrompt(environment?: EnvironmentInfo, customContext?: string): string {
|
||||
let prompt = `你是一个专业的Linux运维AI助手,运行在SSH AI Agent桌面客户端中。
|
||||
你可以使用工具在远程服务器上执行命令、读写文件、查看系统状态来帮助用户完成运维任务。
|
||||
|
||||
**重要对话规则**:
|
||||
1. **先用自然语言回复用户**:除非用户明确要求执行操作,否则先口头回复
|
||||
2. **闲聊/打招呼(你好、哈喽、hi 等)必须直接用文字回复**,绝对不要调用任何工具
|
||||
3. 只有当用户明确要求"执行命令"、"查看"、"读取"、"写入"、"列出"等操作时,才调用相应工具
|
||||
4. **执行任何危险操作前**,必须先告知用户风险
|
||||
5. **修改文件前**要自动备份
|
||||
6. **不要执行 rm -rf / 等极端危险命令**
|
||||
7. 如果不确定用户意图,先询问确认
|
||||
8. 命令输出可能被截断,可以用 read_file 查看完整内容
|
||||
9. 使用 sudo 命令时,用户已预配置 sudo 密码,可以直接使用
|
||||
|
||||
**可用工具说明**(仅在用户明确要求操作时使用):
|
||||
- execute_command: 在远程服务器执行 Shell 命令(需要用户审批,**仅运维任务使用**)
|
||||
- read_file: 读取文件内容(自动批准)
|
||||
- write_file: 写入/创建文件(需要用户审批,自动备份)
|
||||
- list_directory: 列出目录内容(自动批准)
|
||||
- system_info: 查看系统状态(自动批准)`;
|
||||
|
||||
if (environment) {
|
||||
prompt += `\n\n当前服务器环境信息:
|
||||
- 操作系统: ${environment.os}
|
||||
- 主机名: ${environment.hostname || '未知'}
|
||||
- Docker: ${environment.docker ? `已安装 (${environment.dockerVersion || '未知版本'})` : '未检测到'}
|
||||
- 运行中的服务: ${environment.services.join(', ') || '无'}
|
||||
- 内存: ${environment.memoryTotal || '未知'}
|
||||
- 磁盘: ${environment.diskTotal || '未知'}`;
|
||||
}
|
||||
|
||||
if (customContext) {
|
||||
prompt += `\n\n用户自定义环境上下文:\n${customContext}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// 会话级别的 AI 对话状态
|
||||
interface AiConversation {
|
||||
messages: OpenAI.ChatCompletionMessageParam[];
|
||||
environment?: EnvironmentInfo;
|
||||
sessionConfig?: SessionConfig;
|
||||
interrupted: boolean;
|
||||
}
|
||||
|
||||
const conversations = new Map<string, AiConversation>();
|
||||
|
||||
// 构建 ToolContext
|
||||
function buildToolContext(sessionId: string | null): ToolContext {
|
||||
return {
|
||||
sessionId,
|
||||
execCommand: (sid: string, cmd: string) => sshManager.exec(sid, cmd),
|
||||
readFile: async (sid: string, path: string) => {
|
||||
const result = await sshManager.exec(sid, `cat ${path}`);
|
||||
return result.stdout;
|
||||
},
|
||||
writeFile: async (sid: string, path: string, content: string) => {
|
||||
const b64 = Buffer.from(content).toString('base64');
|
||||
await sshManager.exec(sid, `echo '${b64}' | base64 -d > ${path}`);
|
||||
},
|
||||
listDirectory: async (sid: string, path: string) => {
|
||||
const result = await sshManager.exec(sid, `ls -la ${path}`);
|
||||
return result.stdout;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 构建 OpenAI tools 定义
|
||||
function buildOpenAITools(): OpenAI.ChatCompletionTool[] {
|
||||
return getToolDefinitions().map((def) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(def.parameters).map(([key, param]) => [
|
||||
key,
|
||||
{ type: param.type, description: param.description, enum: param.enum },
|
||||
])
|
||||
),
|
||||
required: Object.entries(def.parameters)
|
||||
.filter(([, p]) => p.required)
|
||||
.map(([key]) => key),
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function registerAiHandlers(mainWindow: BrowserWindow) {
|
||||
// AI 聊天
|
||||
ipcMain.on(IPC.AI_CHAT, async (event, sessionId: string | null, message: string, modelConfigId: string) => {
|
||||
const modelConfig = modelStore.list().find((m) => m.id === modelConfigId);
|
||||
if (!modelConfig) {
|
||||
mainWindow.webContents.send(IPC.AI_ERROR, { message: '模型配置不存在,请先配置 AI 模型' });
|
||||
return;
|
||||
}
|
||||
|
||||
const convKey = sessionId || '__global__';
|
||||
if (!conversations.has(convKey)) {
|
||||
const sessionConfig = sessionId ? sessionStore.get(sessionId) : undefined;
|
||||
conversations.set(convKey, {
|
||||
messages: [],
|
||||
environment: undefined,
|
||||
sessionConfig,
|
||||
interrupted: false,
|
||||
});
|
||||
}
|
||||
|
||||
const conv = conversations.get(convKey)!;
|
||||
conv.interrupted = false;
|
||||
|
||||
// 添加用户消息
|
||||
conv.messages.push({ role: 'user', content: message });
|
||||
|
||||
// 构建 system prompt
|
||||
const systemPrompt = buildSystemPrompt(conv.environment, conv.sessionConfig?.customContext);
|
||||
const allMessages: OpenAI.ChatCompletionMessageParam[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...conv.messages,
|
||||
];
|
||||
|
||||
try {
|
||||
const provider = new ModelProvider(modelConfig);
|
||||
const openaiTools = buildOpenAITools();
|
||||
const ctx = buildToolContext(sessionId);
|
||||
|
||||
// ReAct 循环
|
||||
let maxIterations = 10;
|
||||
|
||||
while (maxIterations-- > 0 && !conv.interrupted) {
|
||||
let iterationContent = '';
|
||||
let toolCalls: OpenAI.ChatCompletionMessageToolCall[] = [];
|
||||
|
||||
for await (const chunk of provider.chat(allMessages, openaiTools)) {
|
||||
if (conv.interrupted) break;
|
||||
|
||||
if (chunk.content) {
|
||||
iterationContent += chunk.content; // 增量累加成完整文本
|
||||
mainWindow.webContents.send(IPC.AI_CHAT_STREAM, {
|
||||
content: chunk.content,
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.toolCalls) {
|
||||
toolCalls = chunk.toolCalls;
|
||||
}
|
||||
|
||||
if (chunk.done && !chunk.toolCalls) {
|
||||
// 纯文本回复,循环结束
|
||||
mainWindow.webContents.send(IPC.AI_CHAT_STREAM, { done: true });
|
||||
conv.messages.push({ role: 'assistant', content: iterationContent });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 有工具调用需要处理
|
||||
if (toolCalls.length > 0 && !conv.interrupted) {
|
||||
console.log(`[AI] 工具调用: ${toolCalls.map(t => t.function.name).join(', ')}`);
|
||||
|
||||
// 把本轮 assistant 文本+工具调用一起 push(保留多轮内容)
|
||||
conv.messages.push({
|
||||
role: 'assistant',
|
||||
content: iterationContent || null,
|
||||
tool_calls: toolCalls,
|
||||
} as OpenAI.ChatCompletionMessageParam);
|
||||
|
||||
allMessages.push({
|
||||
role: 'assistant',
|
||||
content: iterationContent || null,
|
||||
tool_calls: toolCalls,
|
||||
} as OpenAI.ChatCompletionMessageParam);
|
||||
|
||||
// 处理每个工具调用
|
||||
for (const tc of toolCalls) {
|
||||
if (conv.interrupted) break;
|
||||
|
||||
const tool = getTool(tc.function.name);
|
||||
if (!tool) {
|
||||
conv.messages.push({
|
||||
role: 'tool',
|
||||
content: `未知工具: ${tc.function.name}`,
|
||||
tool_call_id: tc.id,
|
||||
});
|
||||
allMessages.push({
|
||||
role: 'tool',
|
||||
content: `未知工具: ${tc.function.name}`,
|
||||
tool_call_id: tc.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = JSON.parse(tc.function.arguments);
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
|
||||
// 高风险工具需要用户审批
|
||||
if (tool.definition.riskLevel === 'high') {
|
||||
const desc = tc.function.name === 'execute_command'
|
||||
? `执行命令: \`${args.command}\``
|
||||
: tc.function.name === 'write_file'
|
||||
? `编辑文件: ${args.path}`
|
||||
: `${tc.function.name}: ${JSON.stringify(args)}`;
|
||||
|
||||
// 通知前端展示审批卡片
|
||||
mainWindow.webContents.send(IPC.AI_TOOL_CALL, {
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.function.name,
|
||||
args,
|
||||
description: desc,
|
||||
riskLevel: tool.definition.riskLevel,
|
||||
});
|
||||
|
||||
// 等待用户审批 - 通过 Promise 暂停
|
||||
const approved = await waitForApproval(tc.id);
|
||||
if (!approved) {
|
||||
conv.messages.push({
|
||||
role: 'tool',
|
||||
content: '用户取消了此操作',
|
||||
tool_call_id: tc.id,
|
||||
});
|
||||
allMessages.push({
|
||||
role: 'tool',
|
||||
content: '用户取消了此操作',
|
||||
tool_call_id: tc.id,
|
||||
});
|
||||
mainWindow.webContents.send(IPC.AI_TOOL_RESULT, {
|
||||
toolCallId: tc.id,
|
||||
success: false,
|
||||
output: '用户取消了此操作',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
mainWindow.webContents.send(IPC.AI_TOOL_RESULT, {
|
||||
toolCallId: tc.id,
|
||||
success: true,
|
||||
output: '执行中...',
|
||||
});
|
||||
|
||||
const result = await tool.execute(args, ctx);
|
||||
|
||||
mainWindow.webContents.send(IPC.AI_TOOL_RESULT, {
|
||||
toolCallId: tc.id,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
const toolMessage: OpenAI.ChatCompletionMessageParam = {
|
||||
role: 'tool',
|
||||
content: result.output + (result.error ? `\n[错误: ${result.error}]` : ''),
|
||||
tool_call_id: tc.id,
|
||||
};
|
||||
conv.messages.push(toolMessage);
|
||||
allMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
// 继续循环,让 LLM 处理工具结果
|
||||
continue;
|
||||
}
|
||||
|
||||
// 没有工具调用,循环结束
|
||||
mainWindow.webContents.send(IPC.AI_CHAT_STREAM, { done: true });
|
||||
conv.messages.push({ role: 'assistant', content: iterationContent });
|
||||
return;
|
||||
}
|
||||
|
||||
// 超出最大迭代次数
|
||||
mainWindow.webContents.send(IPC.AI_CHAT_STREAM, { done: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'AI 调用失败';
|
||||
console.error('[AI] 调用失败:', message);
|
||||
mainWindow.webContents.send(IPC.AI_ERROR, { message });
|
||||
}
|
||||
});
|
||||
|
||||
// 工具审批
|
||||
ipcMain.on(IPC.AI_TOOL_ACTION, (_event, toolCallId: string, action: string, editedContent?: string) => {
|
||||
const pending = pendingApprovals.get(toolCallId);
|
||||
if (pending) {
|
||||
if (action === 'cancel') {
|
||||
pending.resolve(false);
|
||||
} else {
|
||||
pending.resolve(true);
|
||||
}
|
||||
pendingApprovals.delete(toolCallId);
|
||||
}
|
||||
});
|
||||
|
||||
// 中断 AI
|
||||
ipcMain.on(IPC.AI_INTERRUPT, () => {
|
||||
for (const [, conv] of conversations) {
|
||||
conv.interrupted = true;
|
||||
}
|
||||
// 拒绝所有等待的审批
|
||||
for (const [, pending] of pendingApprovals) {
|
||||
pending.resolve(false);
|
||||
}
|
||||
pendingApprovals.clear();
|
||||
mainWindow.webContents.send(IPC.AI_CHAT_STREAM, { done: true });
|
||||
});
|
||||
|
||||
// 环境信息更新(由主进程探测环境后推送到自身会话状态,供 AI 上下文使用)
|
||||
// 此处保留为占位 - 实际环境信息已在 sessionHandler 中通过 ENV_DETECTED 事件推送
|
||||
void mainWindow; // keep parameter for API symmetry
|
||||
}
|
||||
|
||||
// ---- 审批等待机制 ----
|
||||
|
||||
const pendingApprovals = new Map<string, { resolve: (approved: boolean) => void }>();
|
||||
|
||||
function waitForApproval(toolCallId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
pendingApprovals.set(toolCallId, { resolve });
|
||||
// 5分钟超时自动取消
|
||||
setTimeout(() => {
|
||||
if (pendingApprovals.has(toolCallId)) {
|
||||
pendingApprovals.delete(toolCallId);
|
||||
resolve(false);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC } from '../../shared/types/ipc';
|
||||
import { modelStore } from '../services/storage/modelStore';
|
||||
|
||||
export function registerModelHandlers() {
|
||||
// 获取模型列表
|
||||
ipcMain.handle(IPC.MODEL_LIST, () => {
|
||||
return modelStore.list();
|
||||
});
|
||||
|
||||
// 保存模型配置
|
||||
ipcMain.handle(IPC.MODEL_SAVE, (_event, config) => {
|
||||
return modelStore.save(config);
|
||||
});
|
||||
|
||||
// 删除模型配置
|
||||
ipcMain.handle(IPC.MODEL_DELETE, (_event, id: string) => {
|
||||
modelStore.delete(id);
|
||||
});
|
||||
|
||||
// 获取预设模板
|
||||
ipcMain.handle(IPC.MODEL_PRESETS, () => {
|
||||
return modelStore.getPresets();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { IPC } from '../../shared/types/ipc';
|
||||
import { sessionStore, groupStore } from '../services/storage/sessionStore';
|
||||
import { sshManager } from '../services/ssh/sshManager';
|
||||
|
||||
export function registerSessionHandlers() {
|
||||
// 获取会话列表
|
||||
ipcMain.handle(IPC.SESSION_LIST, () => sessionStore.list());
|
||||
|
||||
// 创建会话
|
||||
ipcMain.handle(IPC.SESSION_CREATE, (_event, config) => sessionStore.create(config));
|
||||
|
||||
// 更新会话
|
||||
ipcMain.handle(IPC.SESSION_UPDATE, (_event, id: string, updates) =>
|
||||
sessionStore.update(id, updates)
|
||||
);
|
||||
|
||||
// 删除会话
|
||||
ipcMain.handle(IPC.SESSION_DELETE, (_event, id: string) => {
|
||||
sshManager.disconnect(id);
|
||||
sessionStore.delete(id);
|
||||
});
|
||||
|
||||
// 连接SSH
|
||||
ipcMain.handle(IPC.SESSION_CONNECT, async (event, sessionId: string) => {
|
||||
console.log(`[SSH] ====== 收到连接请求: sessionId=${sessionId} ======`);
|
||||
const config = sessionStore.get(sessionId);
|
||||
if (!config) {
|
||||
console.error(`[SSH] 会话配置不存在: ${sessionId}`);
|
||||
throw new Error('会话配置不存在');
|
||||
}
|
||||
console.log(`[SSH] 配置: ${config.username}@${config.host}:${config.port}, authType=${config.authType}, password=${config.password ? '已设置' : '空'}, privateKey=${config.privateKey ? '已设置' : '空'}`);
|
||||
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) {
|
||||
console.error('[SSH] 找不到 BrowserWindow');
|
||||
}
|
||||
|
||||
// 认证方式与凭据校验
|
||||
if (config.authType === 'password' && !config.password) {
|
||||
console.warn('[SSH] 密码为空,提示用户设置');
|
||||
win?.webContents.send('session:status', sessionId, 'error');
|
||||
throw new Error('未设置密码: 请右键会话 → 编辑 输入密码');
|
||||
}
|
||||
if (config.authType === 'privateKey' && !config.privateKey) {
|
||||
console.warn('[SSH] 私钥为空,提示用户设置');
|
||||
win?.webContents.send('session:status', sessionId, 'error');
|
||||
throw new Error('未设置私钥: 请右键会话 → 编辑 输入私钥内容');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[SSH] 发送 connecting 状态...`);
|
||||
win?.webContents.send('session:status', sessionId, 'connecting');
|
||||
console.log(`[SSH] 调用 sshManager.connect()...`);
|
||||
const session = await sshManager.connect(config);
|
||||
console.log(`[SSH] ✓ 连接成功: ${config.username}@${config.host}:${config.port}`);
|
||||
|
||||
// 创建 Shell 并设置数据流
|
||||
const shell = await sshManager.createShell(sessionId);
|
||||
if (!shell) {
|
||||
console.error('[SSH] createShell 返回 null');
|
||||
throw new Error('创建 Shell 失败');
|
||||
}
|
||||
console.log(`[SSH] Shell 创建成功,绑定数据流...`);
|
||||
if (win) {
|
||||
shell.on('data', (data: Buffer) => {
|
||||
win.webContents.send(IPC.TERMINAL_OUTPUT, sessionId, data.toString('utf-8'));
|
||||
});
|
||||
shell.on('close', () => {
|
||||
console.log(`[SSH] Shell closed: ${sessionId}`);
|
||||
win.webContents.send(IPC.TERMINAL_DISCONNECTED, sessionId, 'Shell closed');
|
||||
win.webContents.send('session:status', sessionId, 'disconnected');
|
||||
});
|
||||
shell.stderr?.on('data', (data: Buffer) => {
|
||||
win.webContents.send(IPC.TERMINAL_OUTPUT, sessionId, data.toString('utf-8'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SSH] 发送 connected 状态`);
|
||||
win?.webContents.send(IPC.TERMINAL_CONNECTED, sessionId);
|
||||
win?.webContents.send('session:status', sessionId, 'connected');
|
||||
|
||||
// 异步探测环境
|
||||
sshManager.detectEnvironment(sessionId).then((env) => {
|
||||
win?.webContents.send(IPC.ENV_DETECTED, sessionId, env);
|
||||
}).catch(() => {
|
||||
// 环境探测失败不影响连接
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '连接失败';
|
||||
console.error(`[SSH] ✗ 连接失败 ${config.username}@${config.host}:${config.port}:`, message, err);
|
||||
win?.webContents.send('session:status', sessionId, 'error');
|
||||
throw new Error(message);
|
||||
}
|
||||
});
|
||||
|
||||
// 断开SSH
|
||||
ipcMain.handle(IPC.SESSION_DISCONNECT, (_event, sessionId: string) => {
|
||||
sshManager.disconnect(sessionId);
|
||||
});
|
||||
|
||||
// ---- 分组管理 ----
|
||||
ipcMain.handle(IPC.GROUP_LIST, () => groupStore.list());
|
||||
ipcMain.handle(IPC.GROUP_CREATE, (_event, config) => groupStore.create(config));
|
||||
ipcMain.handle(IPC.GROUP_UPDATE, (_event, id: string, updates) =>
|
||||
groupStore.update(id, updates)
|
||||
);
|
||||
ipcMain.handle(IPC.GROUP_DELETE, (_event, id: string) => groupStore.delete(id));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC } from '../../shared/types/ipc';
|
||||
import { sshManager } from '../services/ssh/sshManager';
|
||||
|
||||
export function registerTerminalHandlers() {
|
||||
// 终端输入
|
||||
ipcMain.on(IPC.TERMINAL_INPUT, (_event, sessionId: string, data: string) => {
|
||||
sshManager.writeShell(sessionId, data);
|
||||
});
|
||||
|
||||
// 终端尺寸调整
|
||||
ipcMain.on(IPC.TERMINAL_RESIZE, (_event, sessionId: string, cols: number, rows: number) => {
|
||||
sshManager.resizeShell(sessionId, cols, rows);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { app, BrowserWindow, ipcMain, Menu } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { registerSessionHandlers } from './handlers/sessionHandler';
|
||||
import { registerTerminalHandlers } from './handlers/terminalHandler';
|
||||
import { registerModelHandlers } from './handlers/modelHandler';
|
||||
import { registerAiHandlers } from './handlers/aiHandler';
|
||||
import { sshManager } from './services/ssh/sshManager';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
// ============== 禁用应用菜单 ==============
|
||||
function buildMenu() {
|
||||
// 设置为空菜单,隐藏顶部菜单栏
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: '智能SSH终端',
|
||||
backgroundColor: '#0a0a0f',
|
||||
frame: false, // 无边框窗口,去掉顶部系统标题栏
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 开发环境加载 dev server,生产环境加载打包文件
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
} else {
|
||||
const rendererPath = path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||
const fallbackPath = path.join(__dirname, '../../renderer/index.html');
|
||||
const htmlPath = fs.existsSync(rendererPath) ? rendererPath : fallbackPath;
|
||||
mainWindow.loadFile(htmlPath);
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
buildMenu();
|
||||
createWindow();
|
||||
|
||||
// 注册窗口控制
|
||||
ipcMain.on('window:close', () => mainWindow?.close());
|
||||
ipcMain.on('window:minimize', () => mainWindow?.minimize());
|
||||
ipcMain.on('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
// 注册 IPC handlers
|
||||
registerSessionHandlers();
|
||||
registerTerminalHandlers();
|
||||
registerModelHandlers();
|
||||
registerAiHandlers(mainWindow!);
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// 清理所有 SSH 连接
|
||||
sshManager.disconnectAll();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
sshManager.disconnectAll();
|
||||
});
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// IPC 通道常量(内联在 preload 中,避免在 asar 内相对 require 失败)
|
||||
const IPC = {
|
||||
SESSION_LIST: 'session:list',
|
||||
SESSION_CREATE: 'session:create',
|
||||
SESSION_UPDATE: 'session:update',
|
||||
SESSION_DELETE: 'session:delete',
|
||||
SESSION_CONNECT: 'session:connect',
|
||||
SESSION_DISCONNECT: 'session:disconnect',
|
||||
GROUP_LIST: 'group:list',
|
||||
GROUP_CREATE: 'group:create',
|
||||
GROUP_UPDATE: 'group:update',
|
||||
GROUP_DELETE: 'group:delete',
|
||||
TERMINAL_INPUT: 'terminal:input',
|
||||
TERMINAL_RESIZE: 'terminal:resize',
|
||||
TERMINAL_OUTPUT: 'terminal:output',
|
||||
TERMINAL_CONNECTED: 'terminal:connected',
|
||||
TERMINAL_DISCONNECTED: 'terminal:disconnected',
|
||||
AI_CHAT: 'ai:chat',
|
||||
AI_CHAT_STREAM: 'ai:chat:stream',
|
||||
AI_TOOL_ACTION: 'ai:tool_action',
|
||||
AI_TOOL_CALL: 'ai:tool_call',
|
||||
AI_TOOL_RESULT: 'ai:tool_result',
|
||||
AI_INTERRUPT: 'ai:interrupt',
|
||||
AI_ERROR: 'ai:error',
|
||||
MODEL_LIST: 'model:list',
|
||||
MODEL_SAVE: 'model:save',
|
||||
MODEL_DELETE: 'model:delete',
|
||||
MODEL_PRESETS: 'model:presets',
|
||||
ENV_DETECTED: 'env:detected',
|
||||
} as const;
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
const electronAPI = {
|
||||
// ---- 窗口控制 ----
|
||||
windowClose: () => ipcRenderer.send('window:close'),
|
||||
windowMinimize: () => ipcRenderer.send('window:minimize'),
|
||||
windowMaximize: () => ipcRenderer.send('window:maximize'),
|
||||
|
||||
// ---- 会话管理 (invoke) ----
|
||||
sessionList: () => ipcRenderer.invoke(IPC.SESSION_LIST),
|
||||
sessionCreate: (config: unknown) => ipcRenderer.invoke(IPC.SESSION_CREATE, config),
|
||||
sessionUpdate: (id: string, updates: unknown) => ipcRenderer.invoke(IPC.SESSION_UPDATE, id, updates),
|
||||
sessionDelete: (id: string) => ipcRenderer.invoke(IPC.SESSION_DELETE, id),
|
||||
sessionConnect: (sessionId: string) => ipcRenderer.invoke(IPC.SESSION_CONNECT, sessionId),
|
||||
sessionDisconnect: (sessionId: string) => ipcRenderer.invoke(IPC.SESSION_DISCONNECT, sessionId),
|
||||
|
||||
// ---- 分组管理 (invoke) ----
|
||||
groupList: () => ipcRenderer.invoke(IPC.GROUP_LIST),
|
||||
groupCreate: (config: unknown) => ipcRenderer.invoke(IPC.GROUP_CREATE, config),
|
||||
groupUpdate: (id: string, updates: unknown) => ipcRenderer.invoke(IPC.GROUP_UPDATE, id, updates),
|
||||
groupDelete: (id: string) => ipcRenderer.invoke(IPC.GROUP_DELETE, id),
|
||||
|
||||
// ---- 终端 (send) ----
|
||||
terminalInput: (sessionId: string, data: string) =>
|
||||
ipcRenderer.send(IPC.TERMINAL_INPUT, sessionId, data),
|
||||
terminalResize: (sessionId: string, cols: number, rows: number) =>
|
||||
ipcRenderer.send(IPC.TERMINAL_RESIZE, sessionId, cols, rows),
|
||||
|
||||
// ---- AI (send) ----
|
||||
aiChat: (sessionId: string | null, message: string, modelConfigId: string) =>
|
||||
ipcRenderer.send(IPC.AI_CHAT, sessionId, message, modelConfigId),
|
||||
aiToolAction: (toolCallId: string, action: string, editedContent?: string) =>
|
||||
ipcRenderer.send(IPC.AI_TOOL_ACTION, toolCallId, action, editedContent),
|
||||
aiInterrupt: () => ipcRenderer.send(IPC.AI_INTERRUPT),
|
||||
|
||||
// ---- 模型配置 (invoke) ----
|
||||
modelList: () => ipcRenderer.invoke(IPC.MODEL_LIST),
|
||||
modelSave: (config: unknown) => ipcRenderer.invoke(IPC.MODEL_SAVE, config),
|
||||
modelDelete: (id: string) => ipcRenderer.invoke(IPC.MODEL_DELETE, id),
|
||||
modelPresets: () => ipcRenderer.invoke(IPC.MODEL_PRESETS),
|
||||
|
||||
// ---- 事件监听 (on) ----
|
||||
onTerminalOutput: (callback: (sessionId: string, data: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, sessionId: string, data: string) => callback(sessionId, data);
|
||||
ipcRenderer.on(IPC.TERMINAL_OUTPUT, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.TERMINAL_OUTPUT, handler);
|
||||
},
|
||||
onTerminalConnected: (callback: (sessionId: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, sessionId: string) => callback(sessionId);
|
||||
ipcRenderer.on(IPC.TERMINAL_CONNECTED, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.TERMINAL_CONNECTED, handler);
|
||||
},
|
||||
onTerminalDisconnected: (callback: (sessionId: string, reason?: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, sessionId: string, reason?: string) => callback(sessionId, reason);
|
||||
ipcRenderer.on(IPC.TERMINAL_DISCONNECTED, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.TERMINAL_DISCONNECTED, handler);
|
||||
},
|
||||
onSessionStatus: (callback: (sessionId: string, status: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, sessionId: string, status: string) => callback(sessionId, status);
|
||||
ipcRenderer.on('session:status', handler);
|
||||
return () => ipcRenderer.removeListener('session:status', handler);
|
||||
},
|
||||
onAiChatStream: (callback: (chunk: { content?: string; done: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, chunk: { content?: string; done: boolean }) => callback(chunk);
|
||||
ipcRenderer.on(IPC.AI_CHAT_STREAM, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.AI_CHAT_STREAM, handler);
|
||||
},
|
||||
onAiToolCall: (callback: (toolCall: unknown) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, toolCall: unknown) => callback(toolCall);
|
||||
ipcRenderer.on(IPC.AI_TOOL_CALL, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.AI_TOOL_CALL, handler);
|
||||
},
|
||||
onAiToolResult: (callback: (result: unknown) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, result: unknown) => callback(result);
|
||||
ipcRenderer.on(IPC.AI_TOOL_RESULT, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.AI_TOOL_RESULT, handler);
|
||||
},
|
||||
onAiError: (callback: (error: { message: string }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, error: { message: string }) => callback(error);
|
||||
ipcRenderer.on(IPC.AI_ERROR, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.AI_ERROR, handler);
|
||||
},
|
||||
onEnvDetected: (callback: (sessionId: string, environment: unknown) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, sessionId: string, environment: unknown) => callback(sessionId, environment);
|
||||
ipcRenderer.on(IPC.ENV_DETECTED, handler);
|
||||
return () => ipcRenderer.removeListener(IPC.ENV_DETECTED, handler);
|
||||
},
|
||||
|
||||
// 移除所有监听
|
||||
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel),
|
||||
};
|
||||
|
||||
export type ElectronAPI = typeof electronAPI;
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
@@ -0,0 +1,100 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { ModelConfig } from '../../../shared/types/ai';
|
||||
|
||||
// 所有模型调用统一走 OpenAI 兼容接口
|
||||
// 用户只需配置 endpoint + model + apiKey 即可接入任意兼容 API
|
||||
export class ModelProvider {
|
||||
private client: OpenAI;
|
||||
private model: string;
|
||||
private maxTokens: number;
|
||||
private temperature: number;
|
||||
|
||||
constructor(config: ModelConfig) {
|
||||
this.client = new OpenAI({
|
||||
apiKey: config.apiKey || 'dummy-key',
|
||||
baseURL: config.endpoint,
|
||||
defaultHeaders: config.customHeaders,
|
||||
});
|
||||
this.model = config.model;
|
||||
this.maxTokens = config.maxTokens || 4096;
|
||||
this.temperature = config.temperature ?? 0.7;
|
||||
}
|
||||
|
||||
async *chat(
|
||||
messages: OpenAI.ChatCompletionMessageParam[],
|
||||
tools?: OpenAI.ChatCompletionTool[]
|
||||
): AsyncGenerator<{
|
||||
content?: string;
|
||||
toolCalls?: OpenAI.ChatCompletionMessageToolCall[];
|
||||
done: boolean;
|
||||
}> {
|
||||
const params: OpenAI.ChatCompletionCreateParamsStreaming = {
|
||||
model: this.model,
|
||||
messages,
|
||||
max_tokens: this.maxTokens,
|
||||
temperature: this.temperature,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
params.tools = tools;
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create(params);
|
||||
|
||||
let content = '';
|
||||
let toolCalls: OpenAI.ChatCompletionMessageToolCall[] = [];
|
||||
let currentToolCall: Partial<OpenAI.ChatCompletionMessageToolCall> | null = null;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
|
||||
if (!delta) continue;
|
||||
|
||||
// 文本内容 - yield 增量,前端 store 用 append 累加成完整内容
|
||||
if (delta.content) {
|
||||
content += delta.content;
|
||||
yield { content: delta.content, done: false };
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (tc.index !== undefined && tc.function) {
|
||||
if (!toolCalls[tc.index]) {
|
||||
toolCalls[tc.index] = {
|
||||
id: tc.id || '',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name || '',
|
||||
arguments: tc.function.arguments || '',
|
||||
},
|
||||
};
|
||||
currentToolCall = toolCalls[tc.index];
|
||||
} else {
|
||||
if (tc.function?.name) {
|
||||
toolCalls[tc.index].function.name += tc.function.name;
|
||||
}
|
||||
if (tc.function?.arguments) {
|
||||
toolCalls[tc.index].function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流结束(finish_reason = tool_calls 表示要进入工具调用循环)
|
||||
if (chunk.choices[0]?.finish_reason === 'tool_calls') {
|
||||
yield { content: undefined, toolCalls, done: false };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 流正常结束(无工具调用)
|
||||
if (toolCalls.length > 0) {
|
||||
yield { content: undefined, toolCalls, done: false };
|
||||
} else {
|
||||
yield { content: undefined, done: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { ToolDefinition, ToolRiskLevel } from '../../../shared/types/ai';
|
||||
|
||||
export interface Tool {
|
||||
definition: ToolDefinition;
|
||||
execute: (args: Record<string, unknown>, context: ToolContext) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
sessionId: string | null;
|
||||
execCommand: (sessionId: string, command: string) => Promise<{ stdout: string; stderr: string; exitCode: number | null }>;
|
||||
readFile: (sessionId: string, path: string) => Promise<string>;
|
||||
writeFile: (sessionId: string, path: string, content: string) => Promise<void>;
|
||||
listDirectory: (sessionId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---- 工具注册中心 ----
|
||||
|
||||
const tools = new Map<string, Tool>();
|
||||
|
||||
export function registerTool(tool: Tool) {
|
||||
tools.set(tool.definition.name, tool);
|
||||
}
|
||||
|
||||
export function getTool(name: string): Tool | undefined {
|
||||
return tools.get(name);
|
||||
}
|
||||
|
||||
export function getAllTools(): Tool[] {
|
||||
return Array.from(tools.values());
|
||||
}
|
||||
|
||||
export function getToolDefinitions(): ToolDefinition[] {
|
||||
return getAllTools().map((t) => t.definition);
|
||||
}
|
||||
|
||||
// ---- 注册内置工具 ----
|
||||
|
||||
// 执行命令工具
|
||||
registerTool({
|
||||
definition: {
|
||||
name: 'execute_command',
|
||||
description: '在远程服务器上执行 Shell 命令。支持 sudo 命令,需要预先配置 sudo 密码。',
|
||||
parameters: {
|
||||
command: { type: 'string', description: '要执行的 Shell 命令', required: true },
|
||||
},
|
||||
riskLevel: 'high',
|
||||
},
|
||||
execute: async (args, ctx) => {
|
||||
const command = args.command as string;
|
||||
if (!ctx.sessionId) {
|
||||
return { success: false, output: '', error: '没有活跃的 SSH 会话' };
|
||||
}
|
||||
try {
|
||||
const result = await ctx.execCommand(ctx.sessionId, command);
|
||||
let output = result.stdout;
|
||||
if (result.stderr) {
|
||||
output += `\n[stderr]\n${result.stderr}`;
|
||||
}
|
||||
if (result.exitCode !== 0 && result.exitCode !== null) {
|
||||
output += `\n[exit code: ${result.exitCode}]`;
|
||||
}
|
||||
return { success: result.exitCode === 0, output: output.substring(0, 8000), error: result.exitCode !== 0 ? `Exit code: ${result.exitCode}` : undefined };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, output: '', error: err instanceof Error ? err.message : '执行失败' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 读取文件工具
|
||||
registerTool({
|
||||
definition: {
|
||||
name: 'read_file',
|
||||
description: '读取远程服务器上的文件内容。支持指定行范围。',
|
||||
parameters: {
|
||||
path: { type: 'string', description: '文件路径', required: true },
|
||||
start_line: { type: 'number', description: '起始行号(可选)' },
|
||||
end_line: { type: 'number', description: '结束行号(可选)' },
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
execute: async (args, ctx) => {
|
||||
const filePath = args.path as string;
|
||||
const startLine = args.start_line as number | undefined;
|
||||
const endLine = args.end_line as number | undefined;
|
||||
if (!ctx.sessionId) {
|
||||
return { success: false, output: '', error: '没有活跃的 SSH 会话' };
|
||||
}
|
||||
try {
|
||||
let cmd = `cat ${filePath}`;
|
||||
if (startLine && endLine) {
|
||||
cmd = `sed -n '${startLine},${endLine}p' ${filePath}`;
|
||||
} else if (startLine) {
|
||||
cmd = `sed -n '${startLine},$p' ${filePath}`;
|
||||
} else if (endLine) {
|
||||
cmd = `sed -n '1,${endLine}p' ${filePath}`;
|
||||
}
|
||||
const result = await ctx.execCommand(ctx.sessionId, cmd);
|
||||
return { success: result.exitCode === 0, output: result.stdout.substring(0, 8000), error: result.exitCode !== 0 ? result.stderr : undefined };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, output: '', error: err instanceof Error ? err.message : '读取失败' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 写入文件工具
|
||||
registerTool({
|
||||
definition: {
|
||||
name: 'write_file',
|
||||
description: '写入或创建远程服务器上的文件。会自动备份原文件。',
|
||||
parameters: {
|
||||
path: { type: 'string', description: '文件路径', required: true },
|
||||
content: { type: 'string', description: '文件内容', required: true },
|
||||
backup: { type: 'boolean', description: '是否备份原文件(默认 true)' },
|
||||
},
|
||||
riskLevel: 'high',
|
||||
},
|
||||
execute: async (args, ctx) => {
|
||||
const filePath = args.path as string;
|
||||
const content = args.content as string;
|
||||
const backup = args.backup !== false;
|
||||
if (!ctx.sessionId) {
|
||||
return { success: false, output: '', error: '没有活跃的 SSH 会话' };
|
||||
}
|
||||
try {
|
||||
// 备份原文件
|
||||
if (backup) {
|
||||
await ctx.execCommand(ctx.sessionId, `cp ${filePath} ${filePath}.bak.$(date +%s) 2>/dev/null || true`);
|
||||
}
|
||||
// 写入文件(使用 base64 编码避免特殊字符问题)
|
||||
const b64 = Buffer.from(content).toString('base64');
|
||||
const result = await ctx.execCommand(ctx.sessionId, `echo '${b64}' | base64 -d > ${filePath}`);
|
||||
return { success: result.exitCode === 0, output: `文件已写入: ${filePath}`, error: result.exitCode !== 0 ? result.stderr : undefined };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, output: '', error: err instanceof Error ? err.message : '写入失败' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 列出目录工具
|
||||
registerTool({
|
||||
definition: {
|
||||
name: 'list_directory',
|
||||
description: '列出远程服务器上目录的内容。',
|
||||
parameters: {
|
||||
path: { type: 'string', description: '目录路径', required: true },
|
||||
recursive: { type: 'boolean', description: '是否递归列出(默认 false)' },
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
execute: async (args, ctx) => {
|
||||
const dirPath = args.path as string;
|
||||
const recursive = args.recursive as boolean;
|
||||
if (!ctx.sessionId) {
|
||||
return { success: false, output: '', error: '没有活跃的 SSH 会话' };
|
||||
}
|
||||
try {
|
||||
const cmd = recursive
|
||||
? `find ${dirPath} -maxdepth 3 -type f -o -type d | head -200`
|
||||
: `ls -la ${dirPath}`;
|
||||
const result = await ctx.execCommand(ctx.sessionId, cmd);
|
||||
return { success: result.exitCode === 0, output: result.stdout.substring(0, 8000), error: result.exitCode !== 0 ? result.stderr : undefined };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, output: '', error: err instanceof Error ? err.message : '列出目录失败' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 系统信息工具
|
||||
registerTool({
|
||||
definition: {
|
||||
name: 'system_info',
|
||||
description: '获取远程服务器的系统状态信息。',
|
||||
parameters: {
|
||||
type: { type: 'string', description: '信息类型: cpu, memory, disk, network, processes', required: true, enum: ['cpu', 'memory', 'disk', 'network', 'processes'] },
|
||||
},
|
||||
riskLevel: 'low',
|
||||
},
|
||||
execute: async (args, ctx) => {
|
||||
const infoType = args.type as string;
|
||||
if (!ctx.sessionId) {
|
||||
return { success: false, output: '', error: '没有活跃的 SSH 会话' };
|
||||
}
|
||||
const commands: Record<string, string> = {
|
||||
cpu: 'top -bn1 | head -5',
|
||||
memory: 'free -h',
|
||||
disk: 'df -h',
|
||||
network: 'ss -tulnp | head -30',
|
||||
processes: 'ps aux --sort=-%mem | head -20',
|
||||
};
|
||||
try {
|
||||
const cmd = commands[infoType] || commands.cpu;
|
||||
const result = await ctx.execCommand(ctx.sessionId, cmd);
|
||||
return { success: result.exitCode === 0, output: result.stdout, error: result.exitCode !== 0 ? result.stderr : undefined };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, output: '', error: err instanceof Error ? err.message : '获取系统信息失败' };
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Client, ClientChannel, type ConnectConfig } from 'ssh2';
|
||||
import type { SessionConfig, EnvironmentInfo } from '../../../shared/types/session';
|
||||
|
||||
export interface SshSession {
|
||||
id: string;
|
||||
config: SessionConfig;
|
||||
client: Client;
|
||||
shell?: ClientChannel;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
class SshManager {
|
||||
private sessions = new Map<string, SshSession>();
|
||||
|
||||
async connect(config: SessionConfig): Promise<SshSession> {
|
||||
// 如果已存在,先断开
|
||||
if (this.sessions.has(config.id)) {
|
||||
this.disconnect(config.id);
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
const session: SshSession = {
|
||||
id: config.id,
|
||||
config,
|
||||
client,
|
||||
connected: false,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
readyTimeout: 15000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
};
|
||||
|
||||
if (config.authType === 'password' && config.password) {
|
||||
connectConfig.password = config.password;
|
||||
} else if (config.authType === 'privateKey' && config.privateKey) {
|
||||
connectConfig.privateKey = config.privateKey;
|
||||
}
|
||||
|
||||
client.on('ready', () => {
|
||||
session.connected = true;
|
||||
this.sessions.set(config.id, session);
|
||||
resolve(session);
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
session.connected = false;
|
||||
reject(new Error(`SSH连接失败: ${err.message}`));
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
session.connected = false;
|
||||
session.shell = undefined;
|
||||
});
|
||||
|
||||
client.on('end', () => {
|
||||
session.connected = false;
|
||||
});
|
||||
|
||||
client.connect(connectConfig);
|
||||
});
|
||||
}
|
||||
|
||||
async createShell(sessionId: string): Promise<ClientChannel | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !session.connected) return null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
session.client.shell(
|
||||
{
|
||||
term: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
console.error(`[SSH] shell() 回调出错: ${err.message}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
session.shell = stream;
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
writeShell(sessionId: string, data: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.shell) return false;
|
||||
session.shell.write(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
resizeShell(sessionId: string, cols: number, rows: number): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.shell) return false;
|
||||
session.shell.setWindow(rows, cols, rows * 16, cols * 8);
|
||||
return true;
|
||||
}
|
||||
|
||||
disconnect(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.shell) {
|
||||
session.shell.close();
|
||||
}
|
||||
if (session.client) {
|
||||
session.client.end();
|
||||
}
|
||||
session.connected = false;
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const sessionId of this.sessions.keys()) {
|
||||
this.disconnect(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
getSession(sessionId: string): SshSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
isConnected(sessionId: string): boolean {
|
||||
return this.sessions.get(sessionId)?.connected ?? false;
|
||||
}
|
||||
|
||||
// 在远程服务器执行命令(非交互式)
|
||||
async exec(sessionId: string, command: string): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.connected) {
|
||||
throw new Error('SSH会话未连接');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(new Error(`执行命令失败: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code: number | null) => {
|
||||
exitCode = code;
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
reject(new Error(`命令流错误: ${err.message}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 检测服务器环境
|
||||
async detectEnvironment(sessionId: string): Promise<EnvironmentInfo> {
|
||||
const commands = {
|
||||
os: 'cat /etc/os-release 2>/dev/null | head -5',
|
||||
hostname: 'hostname 2>/dev/null',
|
||||
docker: 'docker --version 2>/dev/null',
|
||||
services: 'systemctl list-units --type=service --state=running 2>/dev/null | grep -oP "\\S+\\.service" | head -20',
|
||||
dirs: 'ls -la ~ 2>/dev/null | tail -20',
|
||||
cpu: 'nproc 2>/dev/null',
|
||||
memory: 'free -h 2>/dev/null | head -2',
|
||||
disk: 'df -h / 2>/dev/null | tail -1',
|
||||
};
|
||||
|
||||
const results: Record<string, string> = {};
|
||||
for (const [key, cmd] of Object.entries(commands)) {
|
||||
try {
|
||||
const { stdout } = await this.exec(sessionId, cmd);
|
||||
results[key] = stdout.trim();
|
||||
} catch {
|
||||
results[key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
const osMatch = results.os?.match(/PRETTY_NAME="(.+?)"/);
|
||||
const dockerVersion = results.docker?.replace('Docker version ', '').trim();
|
||||
|
||||
const env: EnvironmentInfo = {
|
||||
os: osMatch?.[1] || 'Unknown Linux',
|
||||
hostname: results.hostname || undefined,
|
||||
docker: !!results.docker,
|
||||
dockerVersion: dockerVersion || undefined,
|
||||
services: results.services ? results.services.split('\n').filter(Boolean) : [],
|
||||
commonDirs: [],
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
|
||||
return env;
|
||||
}
|
||||
}
|
||||
|
||||
export const sshManager = new SshManager();
|
||||
@@ -0,0 +1,96 @@
|
||||
import Store from 'electron-store';
|
||||
import type { ModelConfig, ModelPreset } from '../../../shared/types/ai';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
const MODELS_KEY = 'modelConfigs';
|
||||
|
||||
// 预设模型模板
|
||||
export const MODEL_PRESETS: ModelPreset[] = [
|
||||
{
|
||||
id: 'preset-openai-gpt4o',
|
||||
name: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'preset-deepseek-chat',
|
||||
name: 'DeepSeek Chat',
|
||||
provider: 'custom',
|
||||
endpoint: 'https://api.deepseek.com/v1',
|
||||
model: 'deepseek-chat',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'preset-qwen',
|
||||
name: '通义千问',
|
||||
provider: 'custom',
|
||||
endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
model: 'qwen-plus',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'preset-doubao',
|
||||
name: '豆包',
|
||||
provider: 'custom',
|
||||
endpoint: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
model: 'doubao-pro-32k',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'preset-ollama',
|
||||
name: 'Ollama (本地)',
|
||||
provider: 'custom',
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
model: 'qwen2.5:14b',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'preset-lmstudio',
|
||||
name: 'LM Studio (本地)',
|
||||
provider: 'custom',
|
||||
endpoint: 'http://localhost:1234/v1',
|
||||
model: 'local-model',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
];
|
||||
|
||||
export const modelStore = {
|
||||
list(): ModelConfig[] {
|
||||
return store.get(MODELS_KEY, []) as ModelConfig[];
|
||||
},
|
||||
|
||||
save(config: Omit<ModelConfig, 'id' | 'createdAt' | 'updatedAt'>): ModelConfig {
|
||||
const models = this.list();
|
||||
const now = Date.now();
|
||||
const newConfig: ModelConfig = {
|
||||
...config,
|
||||
id: `model_${now}_${Math.random().toString(36).substring(2, 6)}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
models.push(newConfig);
|
||||
store.set(MODELS_KEY, models);
|
||||
return newConfig;
|
||||
},
|
||||
|
||||
update(id: string, updates: Partial<ModelConfig>): ModelConfig | undefined {
|
||||
const models = this.list();
|
||||
const idx = models.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return undefined;
|
||||
models[idx] = { ...models[idx], ...updates, updatedAt: Date.now() };
|
||||
store.set(MODELS_KEY, models);
|
||||
return models[idx];
|
||||
},
|
||||
|
||||
delete(id: string): void {
|
||||
const models = this.list().filter((m) => m.id !== id);
|
||||
store.set(MODELS_KEY, models);
|
||||
},
|
||||
|
||||
getPresets(): ModelPreset[] {
|
||||
return MODEL_PRESETS;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import Store from 'electron-store';
|
||||
import type { SessionConfig, SessionGroup } from '../../../shared/types/session';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
const SESSIONS_KEY = 'sessions';
|
||||
const GROUPS_KEY = 'sessionGroups';
|
||||
|
||||
// 兼容旧数据:旧会话没有 groupId 时补 null
|
||||
function migrateSessions(raw: unknown): SessionConfig[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((s: SessionConfig) => ({
|
||||
...s,
|
||||
groupId: s.groupId === undefined ? null : s.groupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export const sessionStore = {
|
||||
list(): SessionConfig[] {
|
||||
return migrateSessions(store.get(SESSIONS_KEY, []));
|
||||
},
|
||||
|
||||
get(id: string): SessionConfig | undefined {
|
||||
return this.list().find((s) => s.id === id);
|
||||
},
|
||||
|
||||
create(config: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt' | 'order'>): SessionConfig {
|
||||
const sessions = this.list();
|
||||
const now = Date.now();
|
||||
const session: SessionConfig = {
|
||||
...config,
|
||||
groupId: config.groupId ?? null,
|
||||
id: `session_${now}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
order: sessions.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
sessions.push(session);
|
||||
store.set(SESSIONS_KEY, sessions);
|
||||
return session;
|
||||
},
|
||||
|
||||
update(id: string, updates: Partial<SessionConfig>): SessionConfig | undefined {
|
||||
const sessions = this.list();
|
||||
const idx = sessions.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return undefined;
|
||||
sessions[idx] = { ...sessions[idx], ...updates, updatedAt: Date.now() };
|
||||
store.set(SESSIONS_KEY, sessions);
|
||||
return sessions[idx];
|
||||
},
|
||||
|
||||
delete(id: string): void {
|
||||
const sessions = this.list().filter((s) => s.id !== id);
|
||||
store.set(SESSIONS_KEY, sessions);
|
||||
},
|
||||
};
|
||||
|
||||
export const groupStore = {
|
||||
list(): SessionGroup[] {
|
||||
const raw = store.get(GROUPS_KEY, []) as SessionGroup[];
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
},
|
||||
|
||||
get(id: string): SessionGroup | undefined {
|
||||
return this.list().find((g) => g.id === id);
|
||||
},
|
||||
|
||||
create(config: Omit<SessionGroup, 'id' | 'createdAt' | 'updatedAt' | 'order' | 'collapsed'>): SessionGroup {
|
||||
const groups = this.list();
|
||||
const now = Date.now();
|
||||
const group: SessionGroup = {
|
||||
...config,
|
||||
id: `group_${now}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
order: groups.length,
|
||||
collapsed: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
groups.push(group);
|
||||
store.set(GROUPS_KEY, groups);
|
||||
return group;
|
||||
},
|
||||
|
||||
update(id: string, updates: Partial<SessionGroup>): SessionGroup | undefined {
|
||||
const groups = this.list();
|
||||
const idx = groups.findIndex((g) => g.id === id);
|
||||
if (idx === -1) return undefined;
|
||||
groups[idx] = { ...groups[idx], ...updates, updatedAt: Date.now() };
|
||||
store.set(GROUPS_KEY, groups);
|
||||
return groups[idx];
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除分组:递归删除所有子分组及其中会话
|
||||
*/
|
||||
delete(id: string): void {
|
||||
const groups = this.list();
|
||||
const sessions = sessionStore.list();
|
||||
const toDelete = new Set<string>();
|
||||
const collect = (gid: string) => {
|
||||
toDelete.add(gid);
|
||||
groups.filter((g) => g.parentId === gid).forEach((g) => collect(g.id));
|
||||
};
|
||||
collect(id);
|
||||
const remainingGroups = groups.filter((g) => !toDelete.has(g.id));
|
||||
const remainingSessions = sessions.filter((s) => !s.groupId || !toDelete.has(s.groupId));
|
||||
store.set(GROUPS_KEY, remainingGroups);
|
||||
store.set(SESSIONS_KEY, remainingSessions);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// ============================================
|
||||
// AI Types
|
||||
// ============================================
|
||||
|
||||
export type ModelProvider = 'openai' | 'custom';
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
customHeaders?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
isPreset?: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: ToolResult;
|
||||
status: 'pending' | 'approved' | 'executing' | 'completed' | 'cancelled' | 'error';
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ToolRiskLevel = 'low' | 'high';
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, ToolParameter>;
|
||||
riskLevel: ToolRiskLevel;
|
||||
}
|
||||
|
||||
export interface ToolParameter {
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
description: string;
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
}
|
||||
|
||||
export type CommandAction = 'cancel' | 'copy' | 'terminal_exec' | 'ai_exec';
|
||||
export type FileEditAction = 'cancel' | 'manual_edit' | 'allow';
|
||||
|
||||
export interface ModelPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
maxTokens?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './session';
|
||||
export * from './ai';
|
||||
export * from './ipc';
|
||||
@@ -0,0 +1,91 @@
|
||||
// ============================================
|
||||
// IPC Channel Types (Electron Main <-> Renderer)
|
||||
// ============================================
|
||||
|
||||
import type { SessionConfig, SessionGroup, EnvironmentInfo, SessionStatus } from './session';
|
||||
import type { ModelConfig, ChatMessage, ToolCall, ModelPreset } from './ai';
|
||||
|
||||
// ---- IPC 通道名 ----
|
||||
|
||||
export const IPC = {
|
||||
// 会话管理
|
||||
SESSION_LIST: 'session:list',
|
||||
SESSION_CREATE: 'session:create',
|
||||
SESSION_UPDATE: 'session:update',
|
||||
SESSION_DELETE: 'session:delete',
|
||||
SESSION_CONNECT: 'session:connect',
|
||||
SESSION_DISCONNECT: 'session:disconnect',
|
||||
|
||||
// 分组管理
|
||||
GROUP_LIST: 'group:list',
|
||||
GROUP_CREATE: 'group:create',
|
||||
GROUP_UPDATE: 'group:update',
|
||||
GROUP_DELETE: 'group:delete',
|
||||
|
||||
// 终端
|
||||
TERMINAL_INPUT: 'terminal:input',
|
||||
TERMINAL_RESIZE: 'terminal:resize',
|
||||
TERMINAL_OUTPUT: 'terminal:output',
|
||||
TERMINAL_CONNECTED: 'terminal:connected',
|
||||
TERMINAL_DISCONNECTED: 'terminal:disconnected',
|
||||
|
||||
// AI
|
||||
AI_CHAT: 'ai:chat',
|
||||
AI_CHAT_STREAM: 'ai:chat:stream',
|
||||
AI_TOOL_ACTION: 'ai:tool_action',
|
||||
AI_TOOL_CALL: 'ai:tool_call',
|
||||
AI_TOOL_RESULT: 'ai:tool_result',
|
||||
AI_INTERRUPT: 'ai:interrupt',
|
||||
AI_ERROR: 'ai:error',
|
||||
|
||||
// 模型配置
|
||||
MODEL_LIST: 'model:list',
|
||||
MODEL_SAVE: 'model:save',
|
||||
MODEL_DELETE: 'model:delete',
|
||||
MODEL_PRESETS: 'model:presets',
|
||||
|
||||
// 环境
|
||||
ENV_DETECTED: 'env:detected',
|
||||
} as const;
|
||||
|
||||
// ---- IPC 通信类型 ----
|
||||
|
||||
export interface IpcInvoke {
|
||||
'session:list': () => SessionConfig[];
|
||||
'session:create': (config: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt' | 'order'>) => SessionConfig;
|
||||
'session:update': (id: string, updates: Partial<SessionConfig>) => SessionConfig;
|
||||
'session:delete': (id: string) => void;
|
||||
'session:connect': (sessionId: string) => void;
|
||||
'session:disconnect': (sessionId: string) => void;
|
||||
'group:list': () => SessionGroup[];
|
||||
'group:create': (config: Omit<SessionGroup, 'id' | 'createdAt' | 'updatedAt' | 'order' | 'collapsed'>) => SessionGroup;
|
||||
'group:update': (id: string, updates: Partial<SessionGroup>) => SessionGroup;
|
||||
'group:delete': (id: string) => void;
|
||||
'model:list': () => ModelConfig[];
|
||||
'model:save': (config: Omit<ModelConfig, 'id' | 'createdAt' | 'updatedAt'>) => ModelConfig;
|
||||
'model:delete': (id: string) => void;
|
||||
'model:presets': () => ModelPreset[];
|
||||
}
|
||||
|
||||
export interface IpcSend {
|
||||
'terminal:input': (sessionId: string, data: string) => void;
|
||||
'terminal:resize': (sessionId: string, cols: number, rows: number) => void;
|
||||
'ai:chat': (sessionId: string | null, message: string, modelConfigId: string) => void;
|
||||
'ai:tool_action': (toolCallId: string, action: string, editedContent?: string) => void;
|
||||
'ai:interrupt': () => void;
|
||||
}
|
||||
|
||||
export interface IpcOn {
|
||||
'terminal:output': (sessionId: string, data: string) => void;
|
||||
'terminal:connected': (sessionId: string) => void;
|
||||
'terminal:disconnected': (sessionId: string, reason?: string) => void;
|
||||
'ai:chat:stream': (chunk: { content?: string; done: boolean }) => void;
|
||||
'ai:tool_call': (toolCall: { toolCallId: string; toolName: string; args: Record<string, unknown>; description: string; riskLevel: 'low' | 'high' }) => void;
|
||||
'ai:tool_result': (result: { toolCallId: string; success: boolean; output: string; error?: string }) => void;
|
||||
'ai:error': (error: { message: string }) => void;
|
||||
'env:detected': (sessionId: string, environment: EnvironmentInfo) => void;
|
||||
'session:status': (sessionId: string, status: SessionStatus) => void;
|
||||
}
|
||||
|
||||
export * from './session';
|
||||
export * from './ai';
|
||||
@@ -0,0 +1,48 @@
|
||||
// ============================================
|
||||
// Session Types
|
||||
// ============================================
|
||||
|
||||
export type AuthType = 'password' | 'privateKey';
|
||||
|
||||
export interface SessionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
order: number;
|
||||
collapsed: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authType: AuthType;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
sudoPassword?: string;
|
||||
customContext?: string;
|
||||
groupId: string | null;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type SessionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export interface EnvironmentInfo {
|
||||
os: string;
|
||||
osVersion?: string;
|
||||
hostname?: string;
|
||||
docker: boolean;
|
||||
dockerVersion?: string;
|
||||
services: string[];
|
||||
commonDirs: string[];
|
||||
cpuInfo?: string;
|
||||
memoryTotal?: string;
|
||||
diskTotal?: string;
|
||||
detectedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Test if binDownload.js patch works
|
||||
const bd = require('./node_modules/app-builder-lib/out/binDownload.js');
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('d:/test_project/patch_debug.log', '[' + new Date().toISOString() + '] test starting\n', { flag: 'a' });
|
||||
bd.getBin('winCodeSign').then(p => {
|
||||
fs.writeFileSync('d:/test_project/patch_debug.log', '[' + new Date().toISOString() + '] patch worked: ' + p + '\n', { flag: 'a' });
|
||||
console.log('PATH:', p);
|
||||
}).catch(e => {
|
||||
fs.writeFileSync('d:/test_project/patch_debug.log', '[' + new Date().toISOString() + '] patch failed: ' + e.message + '\n', { flag: 'a' });
|
||||
console.log('ERR:', e.message);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"],
|
||||
"exclude": ["src/renderer/**/*", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/renderer/**/*", "src/shared/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'src/renderer',
|
||||
base: './',
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'renderer'),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user