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:
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
AI Agent 配置对话框
|
||||
支持自定义 OpenAI 兼容 API(OpenAI / 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}")
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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 Agent(OpenAI 兼容 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
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
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
@@ -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()}")
|
||||
Reference in New Issue
Block a user