feat: SSHClient v1.0.0 - PyQt5 + paramiko 跨平台 SSH 客户端

功能:
- 多主机管理 (增删改查, 密码/私钥双认证, 导入导出)
- 远程终端 (命令执行 + 常用命令快捷栏 + 超时控制)
- SFTP 文件浏览 (上传/下载带进度, 新建/删除/重命名)
- 实时监控 (CPU/内存/磁盘/网络, 1-10秒可调刷新)
- AI Agent (OpenAI 兼容 API, 5 工具自动调用: 命令/指标/列文件/读文件/上传)

技术栈: PyQt5 + paramiko + psutil + requests + PyInstaller
打包: build_windows.bat / build.sh 一键产出 ~57MB 单文件 exe
测试: core 6/6 + UI 3/3 + E2E 6/6 全部通过
This commit is contained in:
Hermes
2026-07-28 21:01:31 +08:00
commit a57dbc0252
22 changed files with 3294 additions and 0 deletions
View File
+135
View File
@@ -0,0 +1,135 @@
"""
AI Agent 配置对话框
支持自定义 OpenAI 兼容 APIOpenAI / DeepSeek / Moonshot / Ollama 等)
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit, QPushButton,
QComboBox, QTextEdit, QLabel, QDialogButtonBox, QMessageBox, QGroupBox,
)
from core.ai_agent import AIAgent
from core.manager import load_ai_config, save_ai_config
PRESETS = [
("OpenAI 官方", "https://api.openai.com/v1", "gpt-4o-mini"),
("DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat"),
("Moonshot Kimi", "https://api.moonshot.cn/v1", "moonshot-v1-8k"),
("通义千问 (DashScope 兼容)", "https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-turbo"),
("智谱 GLM (BigModel)", "https://open.bigmodel.cn/api/paas/v4", "glm-4-flash"),
("Ollama (本地)", "http://127.0.0.1:11434/v1", "qwen2.5:7b"),
("自定义", "", ""),
]
class AIConfigDialog(QDialog):
def __init__(self, agent: AIAgent, parent=None):
super().__init__(parent)
self.agent = agent
self.setWindowTitle("AI Agent 设置")
self.resize(560, 480)
self._build()
# 用持久化的配置优先,再用 agent 当前值填充
cfg = load_ai_config()
self.api_key_edit.setText(cfg.get("api_key", "") or agent.api_key)
self.base_url_edit.setText(cfg.get("base_url", "") or agent.base_url)
self.model_edit.setText(cfg.get("model", "") or agent.model)
self.system_prompt_edit.setPlainText(
cfg.get("system_prompt", "") or agent.system_prompt
)
# 匹配预设
for i, (_, url, model) in enumerate(PRESETS):
if url and url == self.base_url_edit.text() and model == self.model_edit.text():
self.preset_combo.setCurrentIndex(i)
break
def _build(self):
layout = QVBoxLayout(self)
# 预设
preset_box = QGroupBox("服务商预设")
pv = QVBoxLayout(preset_box)
row = QHBoxLayout()
row.addWidget(QLabel("快速选择:"))
self.preset_combo = QComboBox()
for name, _, _ in PRESETS:
self.preset_combo.addItem(name)
self.preset_combo.currentIndexChanged.connect(self._on_preset_changed)
row.addWidget(self.preset_combo, 1)
pv.addLayout(row)
layout.addWidget(preset_box)
# 表单
form_box = QGroupBox("API 配置")
form = QFormLayout(form_box)
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_key_edit.setPlaceholderText("sk-...")
form.addRow("API Key:", self.api_key_edit)
self.base_url_edit = QLineEdit()
self.base_url_edit.setPlaceholderText("https://api.openai.com/v1")
form.addRow("Base URL:", self.base_url_edit)
self.model_edit = QLineEdit()
self.model_edit.setPlaceholderText("gpt-4o-mini")
form.addRow("Model:", self.model_edit)
layout.addWidget(form_box)
# 系统提示
sp_box = QGroupBox("系统提示词(可自定义 AI 行为)")
sv = QVBoxLayout(sp_box)
self.system_prompt_edit = QTextEdit()
self.system_prompt_edit.setMaximumHeight(120)
sv.addWidget(self.system_prompt_edit)
layout.addWidget(sp_box)
# 按钮
bb = QDialogButtonBox()
self.btn_test = bb.addButton("测试连接", QDialogButtonBox.ActionRole)
self.btn_test.clicked.connect(self._test)
bb.addButton(QDialogButtonBox.Save).clicked.connect(self._save)
bb.addButton(QDialogButtonBox.Cancel).clicked.connect(self.reject)
layout.addWidget(bb)
def _on_preset_changed(self, idx: int):
name, url, model = PRESETS[idx]
if url:
self.base_url_edit.setText(url)
self.model_edit.setText(model)
def _save(self):
api_key = self.api_key_edit.text().strip()
base_url = self.base_url_edit.text().strip()
model = self.model_edit.text().strip()
system_prompt = self.system_prompt_edit.toPlainText().strip()
if not base_url or not model:
QMessageBox.warning(self, "保存失败", "Base URL 和 Model 必填")
return
self.agent.update_config(api_key, base_url, model, system_prompt)
save_ai_config({
"api_key": api_key, "base_url": base_url,
"model": model, "system_prompt": system_prompt,
})
self.accept()
def _test(self):
# 保存到 agent 临时测一下
api_key = self.api_key_edit.text().strip()
base_url = self.base_url_edit.text().strip()
model = self.model_edit.text().strip()
if not api_key or not base_url or not model:
QMessageBox.warning(self, "提示", "请先填好 API Key / Base URL / Model")
return
# 保存一份用于测试的临时 agent
tmp = AIAgent(api_key=api_key, base_url=base_url, model=model)
try:
from PyQt5.QtWidgets import QApplication
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
tmp.chat("ping", lambda n, a: "ok")
finally:
QApplication.restoreOverrideCursor()
QMessageBox.information(self, "成功", "连接成功!")
except Exception as e:
QMessageBox.critical(self, "失败", f"连接失败:\n{e}")
+109
View File
@@ -0,0 +1,109 @@
"""
主机新增/编辑对话框
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit, QSpinBox,
QPushButton, QDialogButtonBox, QFileDialog, QMessageBox, QCheckBox,
QGroupBox, QTabWidget, QWidget, QTextEdit,
)
class HostDialog(QDialog):
def __init__(self, parent=None, current: dict = None):
super().__init__(parent)
self.current = current or {}
title = "编辑主机" if current else "新增主机"
self.setWindowTitle(title)
self.resize(480, 380)
self._build()
self._load()
def _build(self):
v = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("给这台主机起个名字(用于显示)")
form.addRow("名称:", self.name_edit)
self.host_edit = QLineEdit()
self.host_edit.setPlaceholderText("192.168.1.1 或 example.com")
form.addRow("主机/IP:", self.host_edit)
self.port_spin = QSpinBox()
self.port_spin.setRange(1, 65535)
self.port_spin.setValue(22)
form.addRow("端口:", self.port_spin)
self.user_edit = QLineEdit()
self.user_edit.setPlaceholderText("root")
form.addRow("用户名:", self.user_edit)
self.pwd_edit = QLineEdit()
self.pwd_edit.setEchoMode(QLineEdit.Password)
self.pwd_edit.setPlaceholderText("密码(密钥无密码可留空)")
form.addRow("密码:", self.pwd_edit)
# 私钥
key_row = QHBoxLayout()
self.key_edit = QLineEdit()
self.key_edit.setPlaceholderText("可选,例如 C:/Users/xxx/.ssh/id_rsa")
self.btn_browse = QPushButton("浏览...")
self.btn_browse.clicked.connect(self._browse_key)
key_row.addWidget(self.key_edit, 1)
key_row.addWidget(self.btn_browse)
form.addRow("私钥文件:", key_row)
self.show_pwd = QCheckBox("显示密码")
self.show_pwd.toggled.connect(
lambda c: self.pwd_edit.setEchoMode(QLineEdit.Normal if c else QLineEdit.Password))
form.addRow("", self.show_pwd)
self.notes_edit = QTextEdit()
self.notes_edit.setMaximumHeight(60)
self.notes_edit.setPlaceholderText("备注(仅本地保存)")
form.addRow("备注:", self.notes_edit)
v.addLayout(form)
bb = QDialogButtonBox()
bb.addButton(QDialogButtonBox.Save).clicked.connect(self._save)
bb.addButton(QDialogButtonBox.Cancel).clicked.connect(self.reject)
v.addWidget(bb)
def _browse_key(self):
path, _ = QFileDialog.getOpenFileName(
self, "选择私钥文件", "", "所有文件 (*)")
if path:
self.key_edit.setText(path)
def _load(self):
c = self.current
self.name_edit.setText(c.get("name", ""))
self.host_edit.setText(c.get("host", ""))
self.port_spin.setValue(int(c.get("port", 22)))
self.user_edit.setText(c.get("username", ""))
self.pwd_edit.setText(c.get("password", ""))
self.key_edit.setText(c.get("key_path", ""))
self.notes_edit.setPlainText(c.get("notes", ""))
def _save(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
if not host or not user:
QMessageBox.warning(self, "校验失败", "主机和用户名不能为空")
return
self.accept()
def get_value(self) -> dict:
return {
"name": self.name_edit.text().strip() or self.host_edit.text().strip(),
"host": self.host_edit.text().strip(),
"port": self.port_spin.value(),
"username": self.user_edit.text().strip(),
"password": self.pwd_edit.text(),
"key_path": self.key_edit.text().strip(),
"notes": self.notes_edit.toPlainText().strip(),
}
+448
View File
@@ -0,0 +1,448 @@
"""
主窗口
- 左侧:主机列表 + 操作
- 右侧:标签页(终端、文件浏览、监控、AI Agent)
"""
import sys
import time
from typing import Optional
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QIcon, QKeySequence
from PyQt5.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QLineEdit, QLabel, QSplitter, QTabWidget, QPlainTextEdit,
QGroupBox, QFormLayout, QSpinBox, QMessageBox, QStatusBar, QAction,
QFileDialog, QInputDialog, QToolBar, QApplication, QStyle, QShortcut,
)
from core.manager import ConnectionManager
from core.ai_agent import AIAgent
from .workers import ConnectWorker, CommandWorker
from .widgets import FileBrowser, MonitorPanel, AIChatPanel
from .config_dialog import AIConfigDialog
APP_NAME = "SSHClient"
APP_VERSION = "1.0.0"
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} - AI 增强 SSH 客户端")
self.resize(1280, 800)
self.manager = ConnectionManager()
self.agent = AIAgent()
self.current_host_id: Optional[str] = None
self.cmd_worker: Optional[CommandWorker] = None
self.connect_worker: Optional[ConnectWorker] = None
self._build_ui()
self._build_menu()
self._build_statusbar()
self._load_hosts_to_list()
# ============================================================
# UI 构建
# ============================================================
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(6, 6, 6, 6)
root.setSpacing(6)
splitter = QSplitter(Qt.Horizontal)
root.addWidget(splitter)
# ====== 左侧:主机面板 ======
left = QWidget()
lv = QVBoxLayout(left)
lv.setContentsMargins(4, 4, 4, 4)
lv.setSpacing(6)
host_title = QLabel("🖥 主机")
host_title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 4px;")
lv.addWidget(host_title)
self.host_list = QListWidget()
self.host_list.itemSelectionChanged.connect(self._on_host_selected)
self.host_list.itemDoubleClicked.connect(lambda _: self._do_connect())
lv.addWidget(self.host_list, 1)
# 主机操作按钮
btn_grid = QVBoxLayout()
btn_grid.setSpacing(4)
self.btn_add = QPushButton(" 新增主机")
self.btn_add.clicked.connect(self._add_host)
self.btn_edit = QPushButton("✏ 编辑")
self.btn_edit.clicked.connect(self._edit_host)
self.btn_delete = QPushButton("🗑 删除")
self.btn_delete.clicked.connect(self._delete_host)
self.btn_connect = QPushButton("🔌 连接")
self.btn_connect.clicked.connect(self._do_connect)
self.btn_disconnect = QPushButton("⛔ 断开")
self.btn_disconnect.clicked.connect(self._do_disconnect)
for b in (self.btn_add, self.btn_edit, self.btn_delete,
self.btn_connect, self.btn_disconnect):
btn_grid.addWidget(b)
lv.addLayout(btn_grid)
# 连接状态
self.conn_status_label = QLabel("未选择")
self.conn_status_label.setStyleSheet("color: #666; padding: 4px;")
lv.addWidget(self.conn_status_label)
splitter.addWidget(left)
# ====== 右侧:Tab 区 ======
self.tabs = QTabWidget()
self.tabs.setDocumentMode(True)
# Tab1: 终端
self.terminal = self._build_terminal_tab()
self.tabs.addTab(self.terminal, "⌨ 终端")
# Tab2: 文件浏览
self.file_browser = FileBrowser(self.manager)
self.tabs.addTab(self.file_browser, "📁 文件")
# Tab3: 监控
self.monitor = MonitorPanel(self.manager)
self.tabs.addTab(self.monitor, "📊 监控")
# Tab4: AI Agent
self.ai_panel = AIChatPanel(self.manager, self.agent)
self.tabs.addTab(self.ai_panel, "🤖 AI Agent")
splitter.addWidget(self.tabs)
splitter.setSizes([280, 1000])
def _build_terminal_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
v.setContentsMargins(6, 6, 6, 6)
v.setSpacing(4)
# 命令输入
cmd_row = QHBoxLayout()
self.cmd_input = QLineEdit()
self.cmd_input.setPlaceholderText("输入命令,回车执行 (例如: ls -la /tmp)")
self.cmd_input.returnPressed.connect(self._run_command)
QShortcut(QKeySequence("Ctrl+Return"), self.cmd_input,
activated=self._run_command)
self.btn_run = QPushButton("执行")
self.btn_run.clicked.connect(self._run_command)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(5, 600)
self.timeout_spin.setValue(30)
self.timeout_spin.setSuffix("")
cmd_row.addWidget(QLabel("$"))
cmd_row.addWidget(self.cmd_input, 1)
cmd_row.addWidget(QLabel("超时:"))
cmd_row.addWidget(self.timeout_spin)
cmd_row.addWidget(self.btn_run)
v.addLayout(cmd_row)
# 快速命令栏
quick_row = QHBoxLayout()
quick_row.addWidget(QLabel("常用:"))
for label, cmd in [
("pwd && uname -a", "pwd && uname -a"),
("df -h", "df -h"),
("free -h", "free -h"),
("top -bn1 | head -20", "top -bn1 | head -20"),
("netstat -tlnp", "netstat -tlnp 2>/dev/null || ss -tlnp"),
("ls /etc", "ls -la /etc"),
]:
b = QPushButton(label)
b.clicked.connect(lambda _, c=cmd: self.cmd_input.setText(c))
quick_row.addWidget(b)
quick_row.addStretch(1)
v.addLayout(quick_row)
# 输出
self.output = QPlainTextEdit()
self.output.setReadOnly(True)
self.output.setStyleSheet("""
QPlainTextEdit {
background: #0c0c0c;
color: #e0e0e0;
font-family: Consolas, 'Courier New', monospace;
font-size: 10pt;
}
""")
v.addWidget(self.output, 1)
# 输出操作
bottom = QHBoxLayout()
self.btn_clear_out = QPushButton("清空")
self.btn_clear_out.clicked.connect(self.output.clear)
self.btn_copy = QPushButton("复制输出")
self.btn_copy.clicked.connect(lambda: QApplication.clipboard().setText(self.output.toPlainText()))
bottom.addWidget(self.btn_clear_out)
bottom.addWidget(self.btn_copy)
bottom.addStretch(1)
v.addLayout(bottom)
return w
def _build_menu(self):
menubar = self.menuBar()
# 文件
m_file = menubar.addMenu("文件(&F)")
act_export = QAction("导出主机配置", self)
act_export.triggered.connect(self._export_hosts)
m_file.addAction(act_export)
act_import = QAction("导入主机配置", self)
act_import.triggered.connect(self._import_hosts)
m_file.addAction(act_import)
m_file.addSeparator()
act_exit = QAction("退出", self)
act_exit.setShortcut("Ctrl+Q")
act_exit.triggered.connect(self.close)
m_file.addAction(act_exit)
# AI
m_ai = menubar.addMenu("AI(&A)")
act_ai = QAction("⚙ AI 设置...", self)
act_ai.triggered.connect(self._show_ai_config)
m_ai.addAction(act_ai)
act_clear = QAction("清空 AI 对话", self)
act_clear.triggered.connect(lambda: self.ai_panel._clear())
m_ai.addAction(act_clear)
# 帮助
m_help = menubar.addMenu("帮助(&H)")
act_about = QAction("关于", self)
act_about.triggered.connect(self._about)
m_help.addAction(act_about)
def _build_statusbar(self):
self.statusBar().showMessage(f"{APP_NAME} v{APP_VERSION} 就绪")
# ============================================================
# 主机列表
# ============================================================
def _load_hosts_to_list(self):
self.host_list.clear()
for h in self.manager.list_hosts():
item = QListWidgetItem(f"{h.get('name', h.get('host'))}\n {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}")
item.setData(Qt.UserRole, h.get("id"))
self.host_list.addItem(item)
# 默认选中第一项
if self.host_list.count() > 0:
self.host_list.setCurrentRow(0)
self._refresh_status_indicator()
def _on_host_selected(self):
items = self.host_list.selectedItems()
if not items:
self.current_host_id = None
self.terminal_input_set_enabled(False)
return
host_id = items[0].data(Qt.UserRole)
self.current_host_id = host_id
self.file_browser.set_host(host_id)
self.monitor.set_host(host_id)
self.ai_panel.set_host(host_id)
self._refresh_status_indicator()
def _refresh_status_indicator(self):
if not self.current_host_id:
self.conn_status_label.setText("未选择主机")
return
c = self.manager.get_connection(self.current_host_id)
if c and c.connected:
self.conn_status_label.setText(f"🟢 已连接: {c.username}@{c.host}:{c.port}")
self.conn_status_label.setStyleSheet("color: #2e7d32; padding: 4px;")
self.terminal_input_set_enabled(True)
else:
self.conn_status_label.setText("🔴 未连接")
self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;")
self.terminal_input_set_enabled(False)
def terminal_input_set_enabled(self, enabled: bool):
self.cmd_input.setEnabled(enabled)
self.btn_run.setEnabled(enabled)
# ============================================================
# 主机 CRUD
# ============================================================
def _add_host(self):
info = self._prompt_host_info()
if info is None:
return
self.manager.add_host(info)
self._load_hosts_to_list()
self.statusBar().showMessage("已新增主机", 3000)
def _edit_host(self):
if not self.current_host_id:
return
h = self.manager.get_host(self.current_host_id)
if not h:
return
info = self._prompt_host_info(h)
if info is None:
return
self.manager.update_host(self.current_host_id, info)
self._load_hosts_to_list()
self.statusBar().showMessage("已更新主机", 3000)
def _delete_host(self):
if not self.current_host_id:
return
h = self.manager.get_host(self.current_host_id)
if not h:
return
if QMessageBox.question(
self, "确认删除",
f"确定删除主机「{h.get('name', h.get('host'))}」?",
QMessageBox.Yes | QMessageBox.No
) != QMessageBox.Yes:
return
self.manager.remove_host(self.current_host_id)
self._load_hosts_to_list()
self.statusBar().showMessage("已删除", 3000)
def _prompt_host_info(self, current: Optional[dict] = None):
from .host_dialog import HostDialog
dlg = HostDialog(self, current)
if dlg.exec_() == dlg.Accepted:
return dlg.get_value()
return None
# ============================================================
# 连接
# ============================================================
def _do_connect(self):
if not self.current_host_id:
QMessageBox.information(self, "提示", "请先选择一台主机")
return
if self.connect_worker and self.connect_worker.isRunning():
return
h = self.manager.get_host(self.current_host_id)
self.statusBar().showMessage(f"正在连接 {h.get('host')}...")
self.btn_connect.setEnabled(False)
self.connect_worker = ConnectWorker(self.manager, self.current_host_id)
self.connect_worker.finished_with.connect(self._on_connect_done)
self.connect_worker.start()
def _on_connect_done(self, host_id: str, ok: bool, msg: str):
self.btn_connect.setEnabled(True)
if ok:
self.statusBar().showMessage(msg, 5000)
# 同步到 UI
self.file_browser.set_host(host_id)
self.monitor.set_host(host_id)
else:
QMessageBox.critical(self, "连接失败", msg)
self.statusBar().showMessage(f"连接失败: {msg}", 5000)
self._refresh_status_indicator()
def _do_disconnect(self):
if not self.current_host_id:
return
self.manager.disconnect(self.current_host_id)
self.statusBar().showMessage("已断开", 3000)
self._refresh_status_indicator()
# 监控如果开着也会自己检测到
# ============================================================
# 命令执行
# ============================================================
def _run_command(self):
cmd = self.cmd_input.text().strip()
if not cmd:
return
if not self.current_host_id:
QMessageBox.warning(self, "提示", "请先连接主机")
return
conn = self.manager.get_connection(self.current_host_id)
if not conn or not conn.connected:
QMessageBox.warning(self, "提示", "当前主机未连接")
return
self._append_out(f"\n$ {cmd}\n")
self.cmd_input.clear()
self.btn_run.setEnabled(False)
timeout = self.timeout_spin.value()
self.cmd_worker = CommandWorker(conn, cmd, timeout)
self.cmd_worker.finished_with.connect(self._on_cmd_done)
self.cmd_worker.start()
def _on_cmd_done(self, code: int, out: str, err: str):
self.btn_run.setEnabled(True)
if out:
self._append_out(out)
if err:
self._append_out_err(err)
self._append_out(f"[exit={code}]\n")
self.statusBar().showMessage(f"命令完成 (退出码 {code})", 3000)
def _append_out(self, text: str):
self.output.appendPlainText(text.rstrip("\n"))
sb = self.output.verticalScrollBar()
sb.setValue(sb.maximum())
def _append_out_err(self, text: str):
# 简单的 ANSI/颜色:stderr 用红色(PlainTextEdit 不支持富文本,所以加 [ERR] 前缀)
self.output.appendPlainText("[ERR] " + text.rstrip("\n").replace("\n", "\n[ERR] "))
# ============================================================
# AI / 关于
# ============================================================
def _show_ai_config(self):
dlg = AIConfigDialog(self.agent, self)
if dlg.exec_():
self.statusBar().showMessage("AI 配置已更新", 3000)
def _about(self):
QMessageBox.about(
self, "关于",
f"<h3>{APP_NAME} v{APP_VERSION}</h3>"
"<p>基于 PyQt5 + paramiko 的 Windows SSH 客户端</p>"
"<ul>"
"<li>多主机管理与连接</li>"
"<li>远程终端</li>"
"<li>SFTP 文件浏览 / 上传 / 下载</li>"
"<li>CPU / 内存 / 磁盘 / 网络 实时监控</li>"
"<li>AI AgentOpenAI 兼容 API,支持工具调用)</li>"
"</ul>"
"<p style='color:#888;'>本程序使用 paramiko、PyQt5、psutil、requests 等开源库。</p>"
)
def _export_hosts(self):
import json
path, _ = QFileDialog.getSaveFileName(self, "导出主机配置", "hosts.json", "JSON (*.json)")
if not path:
return
with open(path, "w", encoding="utf-8") as f:
json.dump(self.manager.list_hosts(), f, ensure_ascii=False, indent=2)
self.statusBar().showMessage(f"已导出到 {path}", 5000)
def _import_hosts(self):
import json
path, _ = QFileDialog.getOpenFileName(self, "导入主机配置", "", "JSON (*.json)")
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
hosts = json.load(f)
for h in hosts:
if "id" in h:
del h["id"]
self.manager.add_host(h)
self._load_hosts_to_list()
self.statusBar().showMessage(f"已导入 {len(hosts)} 台主机", 5000)
except Exception as e:
QMessageBox.critical(self, "导入失败", str(e))
def closeEvent(self, e):
try:
self.monitor._stop_worker()
self.manager.close_all()
except Exception:
pass
super().closeEvent(e)
+725
View File
@@ -0,0 +1,725 @@
"""
PyQt5 自定义控件
- FileBrowser: 远程文件浏览 + 上传/下载/删除
- MonitorPanel: CPU/内存/磁盘/网络实时监控面板
- AIChatPanel: AI Agent 对话面板
"""
import os
import time
from pathlib import Path
from datetime import datetime
from typing import Optional, List, Dict
from PyQt5.QtCore import Qt, pyqtSignal, QSize
from PyQt5.QtGui import QFont, QColor, QIcon, QPixmap, QPainter, QBrush
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem,
QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout,
QComboBox, QToolButton, QStyle, QApplication, QInputDialog,
QListWidget, QListWidgetItem, QTabWidget,
)
from core.ssh_client import SSHConnection
from core.monitor import SystemMonitor
from core.manager import ConnectionManager
from .workers import (
ListDirWorker, UploadWorker, DownloadWorker, MonitorWorker, AIWorker,
)
# ============================================================
# 文件浏览面板
# ============================================================
class FileBrowser(QWidget):
"""远程 SFTP 文件浏览:路径栏 + 工具栏 + 表格 + 状态栏"""
def __init__(self, manager: ConnectionManager, parent=None):
super().__init__(parent)
self.manager = manager
self.current_host_id: Optional[str] = None
self.current_path: str = "/"
self.cwd_history: List[str] = [] # 简单的前进/后退栈
self.list_worker: Optional[ListDirWorker] = None
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
# 路径栏
nav = QHBoxLayout()
self.btn_back = QPushButton("")
self.btn_back.setFixedWidth(32)
self.btn_back.clicked.connect(self._go_back)
self.btn_up = QPushButton("")
self.btn_up.setFixedWidth(32)
self.btn_up.clicked.connect(self._go_up)
self.path_edit = QLineEdit("/")
self.path_edit.returnPressed.connect(self._go_to_path)
self.btn_refresh = QPushButton("刷新")
self.btn_refresh.clicked.connect(lambda: self._refresh())
nav.addWidget(self.btn_back)
nav.addWidget(self.btn_up)
nav.addWidget(self.path_edit, 1)
nav.addWidget(self.btn_refresh)
layout.addLayout(nav)
# 工具栏
toolbar = QHBoxLayout()
self.btn_upload = QPushButton("⬆ 上传")
self.btn_upload.clicked.connect(self._upload)
self.btn_download = QPushButton("⬇ 下载")
self.btn_download.clicked.connect(self._download)
self.btn_mkdir = QPushButton("新建目录")
self.btn_mkdir.clicked.connect(self._mkdir)
self.btn_delete = QPushButton("删除")
self.btn_delete.clicked.connect(self._delete)
self.btn_rename = QPushButton("重命名")
self.btn_rename.clicked.connect(self._rename)
for w in (self.btn_upload, self.btn_download, self.btn_mkdir,
self.btn_delete, self.btn_rename):
toolbar.addWidget(w)
toolbar.addStretch(1)
self.progress = QProgressBar()
self.progress.setFixedWidth(180)
self.progress.setVisible(False)
toolbar.addWidget(self.progress)
layout.addLayout(toolbar)
# 文件表格
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["名称", "大小", "修改时间", "类型"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setAlternatingRowColors(True)
self.table.doubleClicked.connect(self._on_double_clicked)
layout.addWidget(self.table, 1)
# 状态栏
self.status_label = QLabel("未连接")
self.status_label.setStyleSheet("color: #888;")
layout.addWidget(self.status_label)
# ------- 连接管理 -------
def set_host(self, host_id: str):
self.current_host_id = host_id
self.current_path = "/"
self.path_edit.setText("/")
self.cwd_history.clear()
conn = self.manager.get_connection(host_id)
if conn and conn.connected:
self.status_label.setText(f"已连接: {conn.username}@{conn.host}")
self._refresh()
else:
self.status_label.setText("主机未连接,无法浏览")
self.table.setRowCount(0)
def refresh(self):
self._refresh()
# ------- 内部操作 -------
def _conn(self) -> Optional[SSHConnection]:
if not self.current_host_id:
return None
c = self.manager.get_connection(self.current_host_id)
if not c or not c.connected:
QMessageBox.warning(self, "未连接", "请先在「主机」面板连接当前主机")
return None
return c
def _go_back(self):
if len(self.cwd_history) > 1:
self.cwd_history.pop()
self.current_path = self.cwd_history[-1]
self.path_edit.setText(self.current_path)
self._refresh()
def _go_up(self):
p = self.current_path.rstrip("/")
if not p:
return
parent = os.path.dirname(p) or "/"
self.current_path = parent
self.path_edit.setText(parent)
self._refresh()
def _go_to_path(self):
path = self.path_edit.text().strip() or "/"
self.current_path = path
self._refresh()
def _refresh(self):
conn = self._conn()
if not conn:
return
if self.list_worker and self.list_worker.isRunning():
return
self.status_label.setText(f"加载中: {self.current_path}")
self.list_worker = ListDirWorker(conn, self.current_path)
self.list_worker.finished_with.connect(self._on_list_done)
self.list_worker.start()
def _on_list_done(self, path: str, entries: list):
if path != self.current_path:
return # 用户已跳转
self.table.setRowCount(0)
# 当前目录放第一行
cur = QTableWidgetItem(f"📁 .")
self.table.insertRow(0)
self.table.setItem(0, 0, cur)
self.table.setItem(0, 1, QTableWidgetItem("-"))
self.table.setItem(0, 2, QTableWidgetItem("-"))
self.table.setItem(0, 3, QTableWidgetItem("dir"))
for e in entries:
row = self.table.rowCount()
self.table.insertRow(row)
icon = "📁" if e["is_dir"] else "📄"
self.table.setItem(row, 0, QTableWidgetItem(f"{icon} {e['name']}"))
self.table.setItem(row, 1, QTableWidgetItem(
"-" if e["is_dir"] else SystemMonitor.format_bytes(e["size"])
))
try:
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(e["mtime"]))
except Exception:
ts = "-"
self.table.setItem(row, 2, QTableWidgetItem(ts))
self.table.setItem(row, 3, QTableWidgetItem("dir" if e["is_dir"] else "file"))
if self.current_path not in self.cwd_history or self.cwd_history[-1] != self.current_path:
self.cwd_history.append(self.current_path)
self.status_label.setText(f"路径: {self.current_path} · 共 {len(entries)}")
def _on_double_clicked(self, idx):
row = idx.row()
if row == 0:
self._go_up()
return
name_item = self.table.item(row, 0)
if not name_item:
return
# 去掉 emoji 前缀
name = name_item.text().lstrip("📁📄 ").strip()
type_item = self.table.item(row, 3)
is_dir = type_item and type_item.text() == "dir"
if is_dir:
new_path = self.current_path.rstrip("/") + "/" + name
if not new_path.startswith("/"):
new_path = "/" + new_path
self.current_path = new_path
self.path_edit.setText(new_path)
self._refresh()
else:
self._download_for_item(name)
def _selected_remote_path(self) -> Optional[str]:
rows = self.table.selectionModel().selectedRows()
if not rows:
QMessageBox.information(self, "提示", "请先选中一个文件或目录")
return None
row = rows[0].row()
if row == 0:
return None
name_item = self.table.item(row, 0)
name = name_item.text().lstrip("📁📄 ").strip()
return self.current_path.rstrip("/") + "/" + name
def _upload(self):
conn = self._conn()
if not conn:
return
files, _ = QFileDialog.getOpenFileNames(self, "选择要上传的文件")
if not files:
return
for local in files:
base = os.path.basename(local)
remote = self.current_path.rstrip("/") + "/" + base
self._start_upload(conn, local, remote)
def _start_upload(self, conn, local, remote):
self.progress.setVisible(True)
self.progress.setValue(0)
worker = UploadWorker(conn, local, remote)
worker.progress.connect(lambda d, t: self.progress.setValue(
int(d / t * 100) if t else 0))
worker.finished_with.connect(
lambda ok, msg, w=worker: self._on_upload_done(ok, msg, w))
worker.start()
self._active_workers = getattr(self, "_active_workers", [])
self._active_workers.append(worker)
def _on_upload_done(self, ok, msg, worker):
self.progress.setVisible(False)
if ok:
self.status_label.setText(msg)
self._refresh()
else:
QMessageBox.critical(self, "上传失败", msg)
if worker in getattr(self, "_active_workers", []):
self._active_workers.remove(worker)
def _download(self):
rp = self._selected_remote_path()
if rp:
self._download_for_path(rp)
def _download_for_item(self, name: str):
rp = self.current_path.rstrip("/") + "/" + name
self._download_for_path(rp)
def _download_for_path(self, remote: str):
conn = self._conn()
if not conn:
return
default_name = os.path.basename(remote) or "download"
local, _ = QFileDialog.getSaveFileName(self, "保存到", default_name)
if not local:
return
self.progress.setVisible(True)
self.progress.setValue(0)
worker = DownloadWorker(conn, remote, local)
worker.progress.connect(lambda d, t: self.progress.setValue(
int(d / t * 100) if t else 0))
worker.finished_with.connect(
lambda ok, msg, w=worker: self._on_download_done(ok, msg, w))
worker.start()
self._active_workers = getattr(self, "_active_workers", [])
self._active_workers.append(worker)
def _on_download_done(self, ok, msg, worker):
self.progress.setVisible(False)
if ok:
self.status_label.setText(msg)
else:
QMessageBox.critical(self, "下载失败", msg)
if worker in getattr(self, "_active_workers", []):
self._active_workers.remove(worker)
def _mkdir(self):
conn = self._conn()
if not conn:
return
name, ok = QInputDialog.getText(self, "新建目录", "目录名:")
if not ok or not name.strip():
return
remote = self.current_path.rstrip("/") + "/" + name.strip()
ok2, msg = conn.mkdir(remote)
if ok2:
self._refresh()
else:
QMessageBox.critical(self, "创建失败", msg)
def _delete(self):
conn = self._conn()
if not conn:
return
rp = self._selected_remote_path()
if not rp:
return
if QMessageBox.question(
self, "确认删除",
f"确定要删除 {rp} 吗?\n目录将递归删除需用命令执行。",
QMessageBox.Yes | QMessageBox.No
) != QMessageBox.Yes:
return
ok, msg = conn.remove(rp)
if ok:
self._refresh()
else:
QMessageBox.critical(self, "删除失败", msg)
def _rename(self):
conn = self._conn()
if not conn:
return
rp = self._selected_remote_path()
if not rp:
return
new_name, ok = QInputDialog.getText(
self, "重命名", "新名称:", text=os.path.basename(rp))
if not ok or not new_name.strip():
return
new_path = self.current_path.rstrip("/") + "/" + new_name.strip()
ok2, msg = conn.rename(rp, new_path)
if ok2:
self._refresh()
else:
QMessageBox.critical(self, "重命名失败", msg)
# ============================================================
# 监控面板
# ============================================================
class MonitorPanel(QWidget):
"""实时监控:CPU、内存、磁盘、网络"""
def __init__(self, manager: ConnectionManager, parent=None):
super().__init__(parent)
self.manager = manager
self.current_host_id: Optional[str] = None
self.worker: Optional[MonitorWorker] = None
self._last_net: Dict[str, tuple] = {} # iface -> (rx, ts)
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# 顶部:主机信息 + 控制
head = QHBoxLayout()
self.host_label = QLabel("未连接")
self.host_label.setStyleSheet("font-size: 14pt; font-weight: bold;")
head.addWidget(self.host_label)
head.addStretch(1)
self.interval_combo = QComboBox()
self.interval_combo.addItems(["1 秒", "2 秒", "3 秒", "5 秒", "10 秒"])
self.interval_combo.setCurrentIndex(2)
head.addWidget(QLabel("刷新:"))
head.addWidget(self.interval_combo)
self.btn_toggle = QPushButton("开始监控")
self.btn_toggle.setCheckable(True)
self.btn_toggle.toggled.connect(self._on_toggle)
head.addWidget(self.btn_toggle)
layout.addLayout(head)
# 主机信息
self.info_label = QLabel("-")
self.info_label.setStyleSheet("color: #666;")
layout.addWidget(self.info_label)
# CPU + 内存行
grid = QHBoxLayout()
grid.addWidget(self._build_card("CPU 使用率", "cpu_card"))
grid.addWidget(self._build_card("内存", "mem_card"))
layout.addLayout(grid)
# 负载 + 启动时间
grid2 = QHBoxLayout()
grid2.addWidget(self._build_card("系统负载", "load_card"))
grid2.addWidget(self._build_card("启动时间", "uptime_card"))
layout.addLayout(grid2)
# 磁盘 + 网络
grid3 = QHBoxLayout()
self.disk_group = self._build_table_card("磁盘", ["挂载点", "已用/总大小", "使用率", "进度"])
self.net_group = self._build_table_card("网络", ["网卡", "↓ 接收", "↑ 发送", "速率"])
grid3.addWidget(self.disk_group)
grid3.addWidget(self.net_group)
layout.addLayout(grid3, 1)
def _build_card(self, title: str, name: str) -> QGroupBox:
box = QGroupBox(title)
v = QVBoxLayout(box)
big = QLabel("0%")
big.setObjectName(f"{name}_value")
big.setStyleSheet("font-size: 28pt; font-weight: bold;")
big.setAlignment(Qt.AlignCenter)
v.addWidget(big)
sub = QLabel("-")
sub.setObjectName(f"{name}_sub")
sub.setAlignment(Qt.AlignCenter)
sub.setStyleSheet("color: #888;")
v.addWidget(sub)
return box
def _build_table_card(self, title: str, headers: list) -> QGroupBox:
box = QGroupBox(title)
v = QVBoxLayout(box)
table = QTableWidget(0, len(headers))
table.setHorizontalHeaderLabels(headers)
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
table.verticalHeader().setVisible(False)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
v.addWidget(table)
return box
def _value_label(self, name: str) -> QLabel:
return self.findChild(QLabel, f"{name}_value")
def _sub_label(self, name: str) -> QLabel:
return self.findChild(QLabel, f"{name}_sub")
def set_host(self, host_id: str):
"""切换主机时停止旧监控并刷新 UI"""
self._stop_worker()
self.current_host_id = host_id
if not host_id:
self.host_label.setText("未连接")
return
h = self.manager.get_host(host_id)
if h:
self.host_label.setText(f"{h.get('name', h.get('host'))} ({h.get('host')})")
if self.btn_toggle.isChecked():
self._start_worker()
def _on_toggle(self, checked: bool):
if checked:
self.btn_toggle.setText("停止监控")
self._start_worker()
else:
self.btn_toggle.setText("开始监控")
self._stop_worker()
def _start_worker(self):
if not self.current_host_id:
return
conn = self.manager.get_connection(self.current_host_id)
if not conn or not conn.connected:
self.info_label.setText("⚠ 当前主机未连接")
return
idx = self.interval_combo.currentIndex()
interval = [1, 2, 3, 5, 10][idx]
self._stop_worker()
self.worker = MonitorWorker(conn, interval=interval)
self.worker.sample_ready.connect(self._on_sample)
self.worker.error.connect(lambda m: self.info_label.setText(f"{m}"))
self.worker.start()
self.info_label.setText(f"已启动监控,每 {interval} 秒刷新")
def _stop_worker(self):
if self.worker:
self.worker.stop()
self.worker.wait(2000)
self.worker = None
self._last_net.clear()
def _on_sample(self, m: dict):
if m.get("error"):
self.info_label.setText(f"{m['error']}")
return
# 主机
os_info = m.get("os", "")
krn = m.get("kernel", "")
host = m.get("hostname", "")
self.info_label.setText(f"主机: {host} · 系统: {os_info} · 内核: {krn}")
# CPU
cpu = m.get("cpu", 0)
self._value_label("cpu_card").setText(f"{cpu:.1f}%")
cores = m.get("cores", 1)
self._sub_label("cpu_card").setText(f"{cores} 核 CPU")
# 内存
mp = m.get("mem_percent", 0)
mu = SystemMonitor.format_bytes(m.get("mem_used", 0))
mt = SystemMonitor.format_bytes(m.get("mem_total", 0))
self._value_label("mem_card").setText(f"{mp:.1f}%")
self._sub_label("mem_card").setText(f"{mu} / {mt}")
# 负载
l1, l5, l15 = m.get("load1", 0), m.get("load5", 0), m.get("load15", 0)
self._value_label("load_card").setText(f"{l1:.2f}")
self._sub_label("load_card").setText(
f"5分钟: {l5:.2f} · 15分钟: {l15:.2f} (核心数={cores})"
)
# 启动时间
ut = SystemMonitor.format_uptime(m.get("uptime", 0))
self._value_label("uptime_card").setText(ut)
boot_ts = time.time() - m.get("uptime", 0)
self._sub_label("uptime_card").setText(
f"启动于: {time.strftime('%Y-%m-%d %H:%M', time.localtime(boot_ts))}"
)
# 磁盘表
disks = m.get("disks", [])
disk_table: QTableWidget = self.disk_group.findChild(QTableWidget)
disk_table.setRowCount(len(disks))
for i, d in enumerate(disks):
disk_table.setItem(i, 0, QTableWidgetItem(d["mount"]))
used_s = SystemMonitor.format_bytes(d["used"])
total_s = SystemMonitor.format_bytes(d["total"])
disk_table.setItem(i, 1, QTableWidgetItem(f"{used_s} / {total_s}"))
pct = d["percent"]
disk_table.setItem(i, 2, QTableWidgetItem(f"{pct}%"))
bar = QProgressBar()
bar.setValue(min(pct, 100))
bar.setFormat(f"{pct}%")
disk_table.setCellWidget(i, 3, bar)
# 网络表(计算每秒速率)
nets = m.get("net", [])
net_table: QTableWidget = self.net_group.findChild(QTableWidget)
net_table.setRowCount(len(nets))
now = time.time()
for i, n in enumerate(nets):
rx, tx, iface = n["rx"], n["tx"], n["iface"]
rx_s, tx_s = "-", "-"
if iface in self._last_net:
last_rx, last_tx, last_ts = self._last_net[iface]
dt = max(now - last_ts, 0.001)
rx_speed = (rx - last_rx) / dt
tx_speed = (tx - last_tx) / dt
rx_s = f"{SystemMonitor.format_bytes(rx)} ({SystemMonitor.format_bytes(int(rx_speed))}/s)"
tx_s = f"{SystemMonitor.format_bytes(tx)} ({SystemMonitor.format_bytes(int(tx_speed))}/s)"
else:
rx_s = f"{SystemMonitor.format_bytes(rx)}"
tx_s = f"{SystemMonitor.format_bytes(tx)}"
self._last_net[iface] = (rx, tx, now)
net_table.setItem(i, 0, QTableWidgetItem(iface))
net_table.setItem(i, 1, QTableWidgetItem(rx_s))
net_table.setItem(i, 2, QTableWidgetItem(tx_s))
if iface in self._last_net and len(self._last_net[iface]) == 3:
last_rx, last_tx, last_ts = self._last_net[iface]
dt = max(now - last_ts, 0.001)
rx_speed = (rx - last_rx) / dt
tx_speed = (tx - last_tx) / dt
net_table.setItem(i, 3, QTableWidgetItem(
f"{SystemMonitor.format_bytes(int(rx_speed))}/s ↑{SystemMonitor.format_bytes(int(tx_speed))}/s"))
else:
net_table.setItem(i, 3, QTableWidgetItem("采样中..."))
# ============================================================
# AI Agent 对话面板
# ============================================================
class AIChatPanel(QWidget):
"""AI Agent 对话面板"""
def __init__(self, manager: ConnectionManager, agent, parent=None):
super().__init__(parent)
self.manager = manager
self.agent = agent
self.worker: Optional[AIWorker] = None
self.current_host_id: Optional[str] = None
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
# 顶部状态
head = QHBoxLayout()
self.status_label = QLabel("AI Agent: 未配置")
self.status_label.setStyleSheet("color: #888;")
head.addWidget(self.status_label)
head.addStretch(1)
self.btn_config = QPushButton("⚙ AI 设置")
self.btn_config.clicked.connect(self._show_config)
head.addWidget(self.btn_config)
self.btn_clear = QPushButton("清空对话")
self.btn_clear.clicked.connect(self._clear)
head.addWidget(self.btn_clear)
layout.addLayout(head)
# 快捷指令
quick = QHBoxLayout()
quick.addWidget(QLabel("快捷:"))
for label, prompt in [
("检查状态", "帮我检查当前主机的运行状态,包括 CPU/内存/磁盘/网络"),
("查日志", "查看最近 100 行系统日志,重点关注 error 和 warning"),
("找大文件", "列出 /var/log 目录下最大的 10 个文件"),
("查端口", "查看当前监听的端口以及对应进程"),
]:
b = QPushButton(label)
b.clicked.connect(lambda _, p=prompt: self.input_edit.setText(p))
quick.addWidget(b)
quick.addStretch(1)
layout.addLayout(quick)
# 对话显示
self.chat_view = QTextEdit()
self.chat_view.setReadOnly(True)
self.chat_view.setStyleSheet("""
QTextEdit {
background: #1e1e1e;
color: #e0e0e0;
font-family: Consolas, 'Courier New', monospace;
font-size: 10pt;
}
""")
layout.addWidget(self.chat_view, 1)
# 输入区
input_layout = QHBoxLayout()
self.input_edit = QLineEdit()
self.input_edit.setPlaceholderText("输入问题,回车发送(Shift+回车换行)...")
self.input_edit.returnPressed.connect(self._send)
self.btn_send = QPushButton("发送")
self.btn_send.clicked.connect(self._send)
input_layout.addWidget(self.input_edit, 1)
input_layout.addWidget(self.btn_send)
layout.addLayout(input_layout)
self._append("系统", "AI Agent 已就绪。请先点击「⚙ AI 设置」配置 API Key。")
def set_host(self, host_id: str):
self.current_host_id = host_id
if host_id:
h = self.manager.get_host(host_id)
if h:
self._append("系统", f"已切换到主机: {h.get('name', h.get('host'))}")
def _append(self, role: str, content: str, color: str = ""):
ts = datetime.now().strftime("%H:%M:%S")
color_map = {
"user": "#4fc3f7", "assistant": "#aed581", "assistant_thinking": "#ffb74d",
"tool_call": "#ba68c8", "tool_result": "#90a4ae", "error": "#ef5350",
"system": "#888",
}
c = color or color_map.get(role, "#e0e0e0")
safe = (content or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
safe = safe.replace("\n", "<br>")
role_label_map = {
"user": "", "assistant": "AI", "assistant_thinking": "AI 思考",
"tool_call": "调用", "tool_result": "结果", "error": "错误", "system": "系统",
}
rl = role_label_map.get(role, role)
self.chat_view.append(
f'<span style="color:#666;">[{ts}]</span> '
f'<b style="color:{c};">{rl}</b>: '
f'<span style="color:{c};">{safe}</span>'
)
# 滚动到底
sb = self.chat_view.verticalScrollBar()
sb.setValue(sb.maximum())
def _send(self):
if self.worker and self.worker.isRunning():
return
text = self.input_edit.text().strip()
if not text:
return
if not self.agent.api_key:
QMessageBox.warning(self, "未配置", "请先在「⚙ AI 设置」中配置 API Key")
return
self._append("user", text)
self.input_edit.clear()
self.btn_send.setEnabled(False)
self.btn_send.setText("思考中...")
conn = self.manager.get_connection(self.current_host_id) if self.current_host_id else None
self.worker = AIWorker(self.agent, text, conn)
self.worker.step.connect(self._append)
self.worker.finished_with.connect(self._on_done)
self.worker.error.connect(self._on_error)
self.worker.start()
def _on_done(self, final: str):
self._append("assistant", final)
self.btn_send.setEnabled(True)
self.btn_send.setText("发送")
def _on_error(self, msg: str):
self._append("error", msg)
self.btn_send.setEnabled(True)
self.btn_send.setText("发送")
def _clear(self):
self.agent.clear_history()
self.chat_view.clear()
self._append("系统", "对话历史已清空")
def _show_config(self):
from .config_dialog import AIConfigDialog
dlg = AIConfigDialog(self.agent, self)
if dlg.exec_():
self.status_label.setText(
f"AI Agent: {self.agent.model} @ {self.agent.base_url}")
self._append("系统", "AI 配置已更新")
+218
View File
@@ -0,0 +1,218 @@
"""
后台工作线程:把阻塞操作(连接、执行命令、上传、AI 推理)从 UI 线程中剥离。
所有线程通过信号与 UI 通信,UI 不阻塞。
"""
import time
import traceback
from typing import Optional
from PyQt5.QtCore import QThread, pyqtSignal
from core.ssh_client import SSHConnection
from core.monitor import SystemMonitor
from core.ai_agent import AIAgent
from core.manager import ConnectionManager
class ConnectWorker(QThread):
"""后台建立 SSH 连接"""
finished_with = pyqtSignal(str, bool, str) # host_id, ok, msg
def __init__(self, manager: ConnectionManager, host_id: str):
super().__init__()
self.manager = manager
self.host_id = host_id
def run(self):
conn, ok, msg = self.manager.connect(self.host_id)
self.finished_with.emit(self.host_id, ok, msg)
class CommandWorker(QThread):
"""后台执行远程命令"""
finished_with = pyqtSignal(int, str, str) # exit_code, stdout, stderr
def __init__(self, conn: SSHConnection, command: str, timeout: int = 30):
super().__init__()
self.conn = conn
self.command = command
self.timeout = timeout
def run(self):
try:
code, out, err = self.conn.exec_command(self.command, timeout=self.timeout)
except Exception as e:
code, out, err = -1, "", str(e)
self.finished_with.emit(code, out, err)
class MonitorWorker(QThread):
"""后台采集系统指标;循环模式"""
sample_ready = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, conn: SSHConnection, interval: int = 3):
super().__init__()
self.conn = conn
self.interval = interval
self._stop = False
def stop(self):
self._stop = True
def run(self):
while not self._stop:
try:
m = SystemMonitor.collect(self.conn)
self.sample_ready.emit(m)
except Exception as e:
self.error.emit(str(e))
for _ in range(self.interval * 10):
if self._stop:
return
time.sleep(0.1)
class UploadWorker(QThread):
"""后台 SFTP 上传(带进度)"""
progress = pyqtSignal(int, int) # done, total
finished_with = pyqtSignal(bool, str)
def __init__(self, conn: SSHConnection, local: str, remote: str):
super().__init__()
self.conn = conn
self.local = local
self.remote = remote
def run(self):
try:
ok, msg = self.conn.upload(self.local, self.remote,
progress_cb=lambda d, t: self.progress.emit(d, t))
except Exception as e:
ok, msg = False, f"异常: {e}"
self.finished_with.emit(ok, msg)
class DownloadWorker(QThread):
"""后台 SFTP 下载(带进度)"""
progress = pyqtSignal(int, int)
finished_with = pyqtSignal(bool, str)
def __init__(self, conn: SSHConnection, remote: str, local: str):
super().__init__()
self.conn = conn
self.remote = remote
self.local = local
def run(self):
try:
ok, msg = self.conn.download(self.remote, self.local,
progress_cb=lambda d, t: self.progress.emit(d, t))
except Exception as e:
ok, msg = False, f"异常: {e}"
self.finished_with.emit(ok, msg)
class ListDirWorker(QThread):
"""后台列目录"""
finished_with = pyqtSignal(str, list) # path, entries
def __init__(self, conn: SSHConnection, path: str):
super().__init__()
self.conn = conn
self.path = path
def run(self):
entries = self.conn.list_dir(self.path)
self.finished_with.emit(self.path, entries)
class AIWorker(QThread):
"""后台 AI 对话(避免 UI 卡顿)"""
step = pyqtSignal(str, str) # role, content
finished_with = pyqtSignal(str) # final reply
error = pyqtSignal(str)
def __init__(self, agent: AIAgent, user_msg: str, conn: Optional[SSHConnection]):
super().__init__()
self.agent = agent
self.user_msg = user_msg
self.conn = conn
def _tool(self, name: str, args: dict) -> str:
"""AI 工具调用 -> 真实执行"""
try:
if name == "exec_ssh_command":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
cmd = args.get("command", "")
timeout = min(int(args.get("timeout", 30)), 300)
code, out, err = self.conn.exec_command(cmd, timeout=timeout)
out_s = out[:3000] + ("\n...(stdout 截断)" if len(out) > 3000 else "")
err_s = err[:1500] + ("\n...(stderr 截断)" if len(err) > 1500 else "")
return f"exit={code}\nstdout:\n{out_s}\nstderr:\n{err_s}"
if name == "get_system_metrics":
m = SystemMonitor.collect(self.conn) if self.conn else {}
# 简化展示给模型
summary = (
f"hostname={m.get('hostname','')} os={m.get('os','')} "
f"cpu={m.get('cpu',0):.1f}% cores={m.get('cores',1)} "
f"load1={m.get('load1',0):.2f} "
f"mem={m.get('mem_percent',0):.1f}% "
f"({SystemMonitor.format_bytes(m.get('mem_used',0))}/"
f"{SystemMonitor.format_bytes(m.get('mem_total',0))})"
)
disks = "; ".join(
f"{d['mount']}={d['percent']}%"
for d in m.get("disks", [])
)
nets = "; ".join(
f"{n['iface']}(rx={SystemMonitor.format_bytes(n['rx'])},"
f"tx={SystemMonitor.format_bytes(n['tx'])})"
for n in m.get("net", [])
)
return f"{summary}\ndisks: {disks}\nnet: {nets}"
if name == "list_remote_files":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
path = args.get("path", "/")
entries = self.conn.list_dir(path)
if not entries:
return f"(空目录或无权限: {path})"
lines = []
for e in entries[:200]:
tag = "d" if e["is_dir"] else "-"
lines.append(f"{tag} {e['size']:>10} {e['name']}")
return f"path={path}\n" + "\n".join(lines)
if name == "read_remote_file":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
path = args.get("path", "")
if not path:
return "[错误] 缺少 path 参数"
# 用 cat,避免 SFTP 打开大文件
code, out, err = self.conn.exec_command(f"cat '{path}' 2>&1 | head -c 8000")
return out if code == 0 else f"[错误 exit={code}] {err}"
if name == "upload_local_file":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
local = args.get("local_path", "")
remote = args.get("remote_path", "")
if not local or not remote:
return "[错误] 缺少 local_path 或 remote_path"
import os
if not os.path.isfile(local):
return f"[错误] 本地文件不存在: {local}"
ok, msg = self.conn.upload(local, remote)
return ("成功: " if ok else "失败: ") + msg
return f"[未知工具] {name}"
except Exception as e:
return f"[工具异常 {name}] {e}\n{traceback.format_exc()}"
def run(self):
try:
final = self.agent.chat(self.user_msg, self._tool,
on_step=lambda r, c: self.step.emit(r, c))
self.finished_with.emit(final)
except Exception as e:
self.error.emit(f"{e}\n{traceback.format_exc()}")