运维 AI 命令助手演练

Administrator 6 阅读 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 => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[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 build

electron-builder 会在 dist/ 下产出 Windows 的 .exe(NSIS 安装包)、macOS 的 .dmg、Linux 的 AppImage。用户下载双击安装,首次打开进入设置页,填完信息即可使用。

十、附上打包好的压缩包,可打开直接使用

ops-ai-terminal.zip