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:
Your Name
2026-06-12 18:11:06 +08:00
commit d93b07f0a9
42 changed files with 9649 additions and 0 deletions
View File
+8
View File
@@ -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`) 处理复杂的智能体决策流程。
+51
View File
@@ -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: []
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+50
View File
@@ -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 : The 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: (The CJS bu...e details.
:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
vite v5.4.21 building for production...
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)
鉁?[39m 1528 modules transformed.
rendering chunks...
computing gzip size...
../../renderer/index.html  0.44 kB 鈹?gzip: 0.33 kB
../../renderer/assets/index-qN8e0yep.css  18.94 kB 鈹?gzip: 5.26 kB
../../renderer/assets/index-2IgjmCyK.js 487.67 kB 鈹?gzip: 133.95 kB
鉁?built in 8.70s
鈥?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
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+13
View File
@@ -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>
+19
View File
@@ -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);
}
+7619
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -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
}
}
}
+14
View File
@@ -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

+347
View File
@@ -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);
});
}
+25
View File
@@ -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();
});
}
+111
View File
@@ -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));
}
+15
View File
@@ -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);
});
}
+93
View File
@@ -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;
}
+126
View File
@@ -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);
+100
View File
@@ -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 };
}
}
}
+204
View File
@@ -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 : '获取系统信息失败' };
}
},
});
+214
View File
@@ -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();
+96
View File
@@ -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;
},
};
+110
View File
@@ -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);
},
};
+72
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './session';
export * from './ai';
export * from './ipc';
+91
View File
@@ -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';
+48
View File
@@ -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;
}
+11
View File
@@ -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);
});
+18
View File
@@ -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"]
}
+14
View File
@@ -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/**/*"]
}
+16
View File
@@ -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,
},
});