运维 AI 命令助手演练
一、项目结构
ops-ai-terminal/
├── main.js # Electron 主进程:SSH、AI 调用、安全校验、配置存储
├── preload.js # contextBridge 暴露给渲染进程的 API
├── package.json
├── scripts/
│ └── copy-vendor.js # 把 xterm 静态资源拷到 renderer/vendor
└── renderer/
├── index.html
├── styles.css
├── renderer.js
└── vendor/ # 由 copy-vendor.js 生成技术栈:Electron + ssh2 + xterm.js + openai SDK。选 openai SDK 的原因是你提到的 DeepSeek、通义千问、豆包都提供 OpenAI 兼容端点,只需要换 baseUrl 和 model 就能切换,不需要为每家写适配代码。
二、package.json
{
"name": "ops-ai-terminal",
"version": "1.0.0",
"description": "AI 运维终端:自然语言生成命令并在远程服务器执行",
"main": "main.js",
"scripts": {
"copy-vendor": "node scripts/copy-vendor.js",
"start": "npm run copy-vendor && electron .",
"prebuild": "npm run copy-vendor",
"build": "electron-builder"
},
"dependencies": {
"openai": "^4.60.0",
"ssh2": "^1.15.0"
},
"devDependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"electron": "^31.0.0",
"electron-builder": "^24.13.0"
},
"build": {
"appId": "com.example.opsai",
"productName": "OpsAI Terminal",
"files": [
"main.js",
"preload.js",
"renderer/**/*",
"node_modules/**/*"
],
"directories": { "output": "dist" },
"win": { "target": "nsis" },
"mac": { "target": "dmg" },
"linux": { "target": "AppImage" }
}
}@xterm/* 放在 devDependencies 里,但通过 copy-vendor 拷贝到 renderer/vendor/,打包时随 renderer/**/* 一起进入安装包,避免 asar 内路径解析问题。
三、scripts/copy-vendor.js
const fs = require('fs')
const path = require('path')
const targets = [
['node_modules/@xterm/xterm/lib/xterm.js', 'renderer/vendor/xterm.js'],
['node_modules/@xterm/xterm/css/xterm.css', 'renderer/vendor/xterm.css'],
['node_modules/@xterm/addon-fit/lib/addon-fit.js', 'renderer/vendor/addon-fit.js'],
]
for (const [src, dest] of targets) {
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.copyFileSync(src, dest)
console.log(`copied ${src} -> ${dest}`)
}四、主进程 main.js
主进程承担四件事:配置存储(API Key 和密码用 safeStorage 加密)、SSH 会话管理、AI 调用、安全校验。渲染进程只做界面,所有敏感操作都在主进程完成。
const { app, BrowserWindow, ipcMain, safeStorage } = require('electron')
const path = require('path')
const fs = require('fs')
const { Client } = require('ssh2')
const OpenAI = require('openai')
let mainWindow = null
let sshClient = null
let sshStream = null
// ---------------- 配置存储 ----------------
const CONFIG_PATH = path.join(app.getPath('userData'), 'opsai-config.json')
function encryptField(plain) {
if (!plain) return { value: '', encrypted: false }
if (safeStorage.isEncryptionAvailable()) {
return { value: safeStorage.encryptString(plain).toString('base64'), encrypted: true }
}
return { value: plain, encrypted: false }
}
function decryptField(obj, key) {
if (!obj || !obj[key]) return
const item = obj[key]
if (item.encrypted) {
try { obj[key] = safeStorage.decryptString(Buffer.from(item.value, 'base64')) }
catch { obj[key] = '' }
} else {
obj[key] = item.value || ''
}
}
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return {}
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
if (cfg.llm) decryptField(cfg.llm, 'apiKey')
if (cfg.server) decryptField(cfg.server, 'password')
return cfg
} catch {
return {}
}
}
function saveConfig(cfg) {
const toSave = JSON.parse(JSON.stringify(cfg))
if (toSave.llm) toSave.llm.apiKey = encryptField(toSave.llm.apiKey)
if (toSave.server) toSave.server.password = encryptField(toSave.server.password)
fs.writeFileSync(CONFIG_PATH, JSON.stringify(toSave, null, 2), 'utf-8')
}
ipcMain.handle('config:get', () => loadConfig())
ipcMain.handle('config:save', (e, cfg) => { saveConfig(cfg); return { ok: true } })
// ---------------- 安全规则引擎 ----------------
const SAFETY_RULES = [
{ pattern: /rm\s+-rf\s+\/(\s|$)/i, level: 'block', message: '禁止递归删除根目录' },
{ pattern: /dd\s+.*of=\/dev\/(sd|nvme|vd)/i, level: 'block', message: '禁止直接写入磁盘设备' },
{ pattern: /mkfs(\.\w+)?\s+\/dev\//i, level: 'block', message: '禁止对块设备执行 mkfs' },
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\};:/, level: 'block', message: '禁止 fork 炸弹' },
{ pattern: /iptables\s+-F/i, level: 'warn', message: '清空防火墙规则可能导致连接中断' },
{ pattern: /kill\s+-9/i, level: 'warn', message: 'SIGKILL 可能造成数据丢失' },
{ pattern: /(shutdown|reboot|poweroff)/i, level: 'warn', message: '系统关机/重启命令' },
{ pattern: /systemctl\s+(stop|disable)/i, level: 'warn', message: '停止系统服务请确认影响范围' },
]
function evaluateSafety(command) {
for (const rule of SAFETY_RULES) {
if (rule.pattern.test(command)) {
return { blocked: rule.level === 'block', level: rule.level, reason: rule.message }
}
}
return { blocked: false, level: 'readonly', reason: '' }
}
// ---------------- SSH 会话 ----------------
ipcMain.handle('ssh:connect', async () => {
const cfg = loadConfig()
const s = cfg.server || {}
if (!s.host) throw new Error('请先在设置中填写服务器地址')
if (sshClient) { try { sshClient.end() } catch {} sshClient = null; sshStream = null }
return new Promise((resolve, reject) => {
const client = new Client()
sshClient = client
client.on('ready', () => {
client.shell({ term: 'xterm-256color', cols: 120, rows: 30 }, (err, stream) => {
if (err) return reject(err)
sshStream = stream
stream.on('data', d => mainWindow?.webContents.send('ssh:data', d.toString('utf-8')))
stream.stderr.on('data', d => mainWindow?.webContents.send('ssh:data', d.toString('utf-8')))
stream.on('close', () => {
mainWindow?.webContents.send('ssh:closed')
try { client.end() } catch {}
sshStream = null; sshClient = null
})
resolve({ ok: true })
})
})
client.on('error', err => reject(err))
const opts = { host: s.host, port: s.port || 22, username: s.username || 'root' }
if (s.authType === 'key' && s.privateKey) opts.privateKey = s.privateKey
else opts.password = s.password || ''
client.connect(opts)
})
})
ipcMain.handle('ssh:disconnect', async () => {
if (sshStream) { try { sshStream.end() } catch {} sshStream = null }
if (sshClient) { try { sshClient.end() } catch {} sshClient = null }
return { ok: true }
})
ipcMain.on('ssh:input', (e, data) => { if (sshStream) sshStream.write(data) })
ipcMain.on('ssh:resize', (e, { cols, rows }) => { if (sshStream) sshStream.setWindow(rows, cols) })
// 把命令填入终端输入行,不回车,等用户自己按 Enter
ipcMain.on('ssh:fillCommand', (e, command) => {
if (sshStream) { sshStream.write('\x15'); sshStream.write(command) }
})
// 直接执行(只读命令,或用户已二次确认的变更命令)
ipcMain.on('ssh:executeCommand', (e, command) => {
if (sshStream) { sshStream.write('\x15'); sshStream.write(command + '\r') }
})
// ---------------- AI 生成 ----------------
const SYSTEM_PROMPT = `你是一个严谨的 Linux 运维命令助手。
用户描述一个运维需求,你需要生成 1-3 条候选命令。
输出必须是 JSON,格式:
{"commands":[{"command":"完整shell命令","explanation":"一句话说明","risk_level":"readonly|change|high_risk","rollback":"变更类命令的回滚命令,只读填null"}]}
规则:
1. 命令必须可直接在 Linux shell 中执行。
2. 不确定时 risk_level 至少为 change。
3. 不要编造不存在的参数。
4. 只输出 JSON,不要多余文字。`
ipcMain.handle('ai:generate', async (e, query) => {
const cfg = loadConfig()
const llm = cfg.llm || {}
if (!llm.apiKey) throw new Error('请先在设置中填写 API Key')
if (!llm.model || !llm.baseUrl) throw new Error('请先在设置中选择模型')
const client = new OpenAI({ baseURL: llm.baseUrl, apiKey: llm.apiKey })
const resp = await client.chat.completions.create({
model: llm.model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `需求:${query}` },
],
response_format: { type: 'json_object' },
temperature: 0.1,
})
let parsed
try { parsed = JSON.parse(resp.choices[0].message.content) }
catch { throw new Error('模型返回不是合法 JSON') }
const list = parsed.commands || []
return list.map(c => ({
command: c.command || '',
explanation: c.explanation || '',
riskLevel: c.risk_level || 'change',
rollback: c.rollback || null,
safety: evaluateSafety(c.command || ''),
}))
})
// ---------------- 窗口 ----------------
function createWindow() {
mainWindow = new BrowserWindow({
width: 1240,
height: 820,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'))
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })五、preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('opsai', {
getConfig: () => ipcRenderer.invoke('config:get'),
saveConfig: (cfg) => ipcRenderer.invoke('config:save', cfg),
connect: () => ipcRenderer.invoke('ssh:connect'),
disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
sendInput: (d) => ipcRenderer.send('ssh:input', d),
resize: (cols, rows) => ipcRenderer.send('ssh:resize', { cols, rows }),
fillCommand: (cmd) => ipcRenderer.send('ssh:fillCommand', cmd),
executeCommand: (cmd) => ipcRenderer.send('ssh:executeCommand', cmd),
generate: (q) => ipcRenderer.invoke('ai:generate', q),
onSshData: (cb) => ipcRenderer.on('ssh:data', (e, d) => cb(d)),
onSshClosed: (cb) => ipcRenderer.on('ssh:closed', () => cb()),
})六、renderer/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>OpsAI Terminal</title>
<link rel="stylesheet" href="./vendor/xterm.css" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<!-- 设置页 -->
<section id="settings-view">
<h1>连接与模型设置</h1>
<fieldset>
<legend>服务器</legend>
<label>主机 IP <input id="host" placeholder="192.168.1.10" /></label>
<label>端口 <input id="port" type="number" value="22" /></label>
<label>用户名 <input id="username" value="root" /></label>
<div class="radio-row">
<label><input type="radio" name="authType" value="password" checked /> 密码</label>
<label><input type="radio" name="authType" value="key" /> 密钥</label>
</div>
<label id="passwordField">密码 <input id="password" type="password" /></label>
<label id="keyField" style="display:none">私钥内容
<textarea id="privateKey" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
</label>
</fieldset>
<fieldset>
<legend>AI 模型</legend>
<label>提供商
<select id="provider">
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="doubao">豆包</option>
<option value="openai">OpenAI</option>
<option value="custom">自定义</option>
</select>
</label>
<label>Base URL <input id="baseUrl" /></label>
<label>模型名 <input id="model" /></label>
<label>API Key <input id="apiKey" type="password" /></label>
</fieldset>
<button id="saveBtn" class="primary">保存并连接</button>
</section>
<!-- 终端页 -->
<section id="terminal-view" style="display:none">
<header>
<span id="status" class="status">未连接</span>
<span id="serverInfo" class="server-info"></span>
<button id="reconnectBtn">重连</button>
<button id="settingsBtn">设置</button>
</header>
<div id="terminal"></div>
<div id="ai-panel">
<div class="ai-input-row">
<input id="aiInput" placeholder="用自然语言描述你的需求,例如:看看磁盘哪个目录占用最大" />
<button id="askBtn" class="primary">生成</button>
</div>
<div id="candidates"></div>
</div>
</section>
<script src="./vendor/xterm.js"></script>
<script src="./vendor/addon-fit.js"></script>
<script src="./renderer.js"></script>
</body>
</html>七、renderer/styles.css
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
/* 设置页 */
#settings-view {
display: flex; flex-direction: column; gap: 18px;
padding: 32px; max-width: 640px; margin: 0 auto; height: 100%; overflow-y: auto;
}
#settings-view h1 { font-size: 20px; margin-bottom: 8px; }
fieldset { border: 1px solid #d0d5dd; border-radius: 8px; padding: 16px; }
legend { padding: 0 6px; font-weight: 600; color: #344054; }
label { display: block; margin: 10px 0; font-size: 14px; color: #475467; }
input, textarea, select {
width: 100%; margin-top: 4px; padding: 8px 10px;
border: 1px solid #d0d5dd; border-radius: 6px; font-size: 14px;
}
.radio-row { display: flex; gap: 20px; margin: 8px 0; }
.radio-row label { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
.radio-row input { width: auto; margin: 0; }
button {
padding: 8px 16px; border: 1px solid #d0d5dd; border-radius: 6px;
background: #fff; cursor: pointer; font-size: 14px;
}
button.primary { background: #2563eb; color: #fff; border-color: #2563eb; }
button.primary:hover { background: #1d4ed8; }
/* 终端页 */
#terminal-view { display: flex; flex-direction: column; height: 100%; }
#terminal-view header {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; background: #111827; color: #e5e7eb; font-size: 13px;
}
.status { padding: 2px 8px; border-radius: 999px; background: #374151; }
.status.green { background: #065f46; }
.status.red { background: #991b1b; }
.status.yellow { background: #92400e; }
.server-info { flex: 1; color: #9ca3af; }
#terminal-view header button { background: #1f2937; color: #e5e7eb; border-color: #374151; }
#terminal { flex: 1; background: #1e1e1e; padding: 6px; min-height: 0; }
#terminal .xterm { height: 100%; }
#ai-panel {
border-top: 1px solid #d0d5dd; background: #f9fafb;
padding: 10px 16px; max-height: 40%; overflow-y: auto;
}
.ai-input-row { display: flex; gap: 8px; }
.ai-input-row input { flex: 1; }
#candidates { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.candidate {
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px;
}
.candidate .cmd {
font-family: Menlo, Monaco, monospace; font-size: 13px;
background: #f3f4f6; padding: 8px; border-radius: 6px; word-break: break-all;
}
.candidate .meta { color: #6b7280; font-size: 13px; margin: 6px 0; }
.candidate .risk { font-size: 12px; margin: 4px 0; }
.risk-readonly { color: #047857; }
.risk-change { color: #b45309; }
.risk-high_risk { color: #b91c1c; }
.candidate .rollback { font-size: 12px; color: #6b7280; }
.candidate .actions { display: flex; gap: 8px; margin-top: 8px; }
.candidate .blocked { color: #b91c1c; font-size: 13px; font-weight: 600; }
.loading, .error { font-size: 13px; color: #6b7280; }
.error { color: #b91c1c; }八、renderer/renderer.js
const $ = (s) => document.querySelector(s)
const PROVIDERS = {
deepseek: { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
doubao: { baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k' },
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
custom: { baseUrl: '', model: '' },
}
let term = null
let fitAddon = null
// ---------------- 视图切换 ----------------
function showSettings() {
$('#settings-view').style.display = 'flex'
$('#terminal-view').style.display = 'none'
}
function showTerminal() {
$('#settings-view').style.display = 'none'
$('#terminal-view').style.display = 'flex'
setTimeout(() => { fitAddon?.fit(); term?.focus() }, 50)
}
// ---------------- 终端 ----------------
function initTerminal() {
term = new Terminal({
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: 14,
cursorBlink: true,
theme: { background: '#1e1e1e', foreground: '#d4d4d4' },
})
fitAddon = new FitAddon.FitAddon()
term.loadAddon(fitAddon)
term.open($('#terminal'))
fitAddon.fit()
term.onData(d => window.opsai.sendInput(d))
term.onResize(({ cols, rows }) => window.opsai.resize(cols, rows))
window.opsai.onSshData(d => term.write(d))
window.opsai.onSshClosed(() => setStatus('已断开', 'red'))
window.addEventListener('resize', () => fitAddon.fit())
}
function setStatus(text, color) {
const el = $('#status')
el.textContent = text
el.className = 'status' + (color ? ' ' + color : '')
}
async function connect() {
setStatus('连接中…', 'yellow')
try {
await window.opsai.connect()
setStatus('已连接', 'green')
const cfg = await window.opsai.getConfig()
$('#serverInfo').textContent = `${cfg.server?.username}@${cfg.server?.host}:${cfg.server?.port}`
} catch (e) {
setStatus('连接失败:' + e.message, 'red')
}
}
// ---------------- 设置页 ----------------
function toggleAuthFields() {
const t = document.querySelector('input[name="authType"]:checked').value
$('#passwordField').style.display = t === 'password' ? 'block' : 'none'
$('#keyField').style.display = t === 'key' ? 'block' : 'none'
}
function applyProvider(p) {
const preset = PROVIDERS[p]
if (!preset) return
if (p !== 'custom') {
$('#baseUrl').value = preset.baseUrl
$('#model').value = preset.model
$('#baseUrl').readOnly = true
$('#model').readOnly = false
} else {
$('#baseUrl').readOnly = false
}
}
async function loadSettingsIntoForm() {
const cfg = await window.opsai.getConfig()
const s = cfg.server || {}
const l = cfg.llm || {}
$('#host').value = s.host || ''
$('#port').value = s.port || 22
$('#username').value = s.username || 'root'
$('#password').value = s.password || ''
$('#privateKey').value = s.privateKey || ''
const auth = s.authType || 'password'
document.querySelector(`input[name="authType"][value="${auth}"]`).checked = true
toggleAuthFields()
$('#provider').value = l.provider || 'deepseek'
applyProvider($('#provider').value)
if (l.baseUrl) $('#baseUrl').value = l.baseUrl
if (l.model) $('#model').value = l.model
$('#apiKey').value = l.apiKey || ''
}
async function saveSettings() {
const cfg = {
server: {
host: $('#host').value.trim(),
port: parseInt($('#port').value, 10) || 22,
username: $('#username').value.trim() || 'root',
authType: document.querySelector('input[name="authType"]:checked').value,
password: $('#password').value,
privateKey: $('#privateKey').value,
},
llm: {
provider: $('#provider').value,
baseUrl: $('#baseUrl').value.trim(),
model: $('#model').value.trim(),
apiKey: $('#apiKey').value.trim(),
},
}
await window.opsai.saveConfig(cfg)
showTerminal()
connect()
}
// ---------------- AI ----------------
async function askAI() {
const q = $('#aiInput').value.trim()
if (!q) return
$('#aiInput').value = ''
$('#candidates').innerHTML = '<div class="loading">生成中…</div>'
try {
const list = await window.opsai.generate(q)
renderCandidates(list)
} catch (e) {
$('#candidates').innerHTML = `<div class="error">生成失败:${escapeHtml(e.message)}</div>`
}
}
function renderCandidates(list) {
const box = $('#candidates')
if (!list.length) {
box.innerHTML = '<div class="error">没有生成候选命令</div>'
return
}
box.innerHTML = ''
list.forEach(c => {
const el = document.createElement('div')
el.className = 'candidate'
const blocked = c.safety.blocked
el.innerHTML = `
<div class="cmd">${escapeHtml(c.command)}</div>
<div class="meta">${escapeHtml(c.explanation)}</div>
<div class="risk risk-${c.safety.level}">风险:${c.safety.level}${
c.safety.reason ? ' — ' + escapeHtml(c.safety.reason) : ''
}</div>
${c.rollback ? `<div class="rollback">回滚:${escapeHtml(c.rollback)}</div>` : ''}
<div class="actions">
${blocked
? '<span class="blocked">已拦截,不可执行</span>'
: '<button class="btn-fill">填入终端</button><button class="btn-exec primary">执行</button>'}
</div>
`
if (!blocked) {
el.querySelector('.btn-fill').onclick = () => {
window.opsai.fillCommand(c.command)
term.focus()
}
el.querySelector('.btn-exec').onclick = () => {
if (c.safety.level !== 'readonly') {
const ok = confirm(`确认执行变更类命令?\n\n${c.command}\n\n风险:${c.safety.level}`)
if (!ok) return
}
window.opsai.executeCommand(c.command)
term.focus()
}
}
box.appendChild(el)
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, m => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[m]))
}
// ---------------- 启动 ----------------
async function boot() {
initTerminal()
document.querySelectorAll('input[name="authType"]').forEach(r => r.onchange = toggleAuthFields)
$('#provider').onchange = () => applyProvider($('#provider').value)
$('#saveBtn').onclick = saveSettings
$('#settingsBtn').onclick = async () => { await loadSettingsIntoForm(); showSettings() }
$('#reconnectBtn').onclick = connect
$('#askBtn').onclick = askAI
$('#aiInput').addEventListener('keydown', e => { if (e.key === 'Enter') askAI() })
await loadSettingsIntoForm()
const cfg = await window.opsai.getConfig()
const ready = cfg.server?.host && cfg.llm?.apiKey && cfg.llm?.model
if (ready) {
showTerminal()
connect()
} else {
showSettings()
}
}
boot()九、运行与打包
开发运行:
npm install
npm start首次启动会进入设置页。填好服务器 IP、端口、用户名、密码(或私钥),从下拉框选择 DeepSeek / 通义千问 / 豆包 / OpenAI,填上对应 API Key,点“保存并连接”。连接成功后进入终端。
打包成可执行文件:
npm run buildelectron-builder 会在 dist/ 下产出 Windows 的 .exe(NSIS 安装包)、macOS 的 .dmg、Linux 的 AppImage。用户下载双击安装,首次打开进入设置页,填完信息即可使用。