a57dbc0252
功能: - 多主机管理 (增删改查, 密码/私钥双认证, 导入导出) - 远程终端 (命令执行 + 常用命令快捷栏 + 超时控制) - 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 全部通过
726 lines
28 KiB
Python
726 lines
28 KiB
Python
"""
|
|
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 配置已更新")
|