feat: remove API AI, add SSH key auth, Termius-style UI, export/import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Андрей Бобырев
2026-05-26 17:44:52 +03:00
parent 934ce2186c
commit 419e268ee2

View File

@@ -15,8 +15,6 @@ import re
import base64 import base64
import socket import socket
import urllib.request import urllib.request
import urllib.error
import ssl
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import stat import stat
@@ -733,11 +731,47 @@ class SessionStore:
self.sessions[index].update(kwargs) self.sessions[index].update(kwargs)
self.save() self.save()
def duplicate(self, index):
"""Duplicate a session (copy with ' (копия)' suffix)."""
if 0 <= index < len(self.sessions):
s = dict(self.sessions[index])
s["name"] = s.get("name", "") + " (копия)"
s["created"] = datetime.now().isoformat()
self.sessions.insert(index + 1, s)
self.save()
return s
def move_to_group(self, index, group): def move_to_group(self, index, group):
if 0 <= index < len(self.sessions): if 0 <= index < len(self.sessions):
self.sessions[index]["group"] = group self.sessions[index]["group"] = group
self.save() self.save()
def export_json(self):
"""Export sessions + groups as dict (passwords decoded for portability)."""
data = {"groups": list(self.groups), "sessions": []}
for s in self.sessions:
e = dict(s)
e["password"] = self._decode(e.get("password", ""))
data["sessions"].append(e)
return data
def import_json(self, data):
"""Import sessions from export dict. Merges, doesn't overwrite."""
for g in data.get("groups", []):
if g and g not in self.groups:
self.groups.append(g)
for s in data.get("sessions", []):
s["password"] = self._encode(s.get("password", ""))
# Skip exact duplicates
dup = False
for existing in self.sessions:
if existing["host"] == s.get("host") and existing["port"] == s.get("port") and existing["user"] == s.get("user"):
dup = True
break
if not dup:
self.sessions.append(s)
self.save()
def get_password(self, session): def get_password(self, session):
return self._decode(session.get("password", "")) return self._decode(session.get("password", ""))
@@ -838,20 +872,28 @@ class TerminalWidget(tk.Frame):
self.text.tag_configure("error", foreground="#f38ba8") self.text.tag_configure("error", foreground="#f38ba8")
self.text.tag_configure("info", foreground="#89b4fa") self.text.tag_configure("info", foreground="#89b4fa")
def connect(self, host, port, user, password): def connect(self, host, port, user, password, key_file=None):
self.running = True self.running = True
self._write(f"Подключение к {user}@{host}:{port}...\n", "info") self._write(f"Подключение к {user}@{host}:{port}...\n", "info")
t = threading.Thread(target=self._connect_thread, args=(host, port, user, password), daemon=True) t = threading.Thread(target=self._connect_thread, args=(host, port, user, password, key_file), daemon=True)
t.start() t.start()
def _connect_thread(self, host, port, user, password): def _connect_thread(self, host, port, user, password, key_file=None):
try: try:
self.ssh = paramiko.SSHClient() self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.ssh.connect( connect_kw = dict(host=host, port=port, username=user, timeout=10)
host, port=port, username=user, password=password, if key_file and os.path.isfile(key_file):
timeout=10, look_for_keys=False, allow_agent=False, connect_kw["key_filename"] = key_file
) connect_kw["look_for_keys"] = False
connect_kw["allow_agent"] = False
if password:
connect_kw["passphrase"] = password
else:
connect_kw["password"] = password
connect_kw["look_for_keys"] = False
connect_kw["allow_agent"] = False
self.ssh.connect(**connect_kw)
cols = max(80, self.text.winfo_width() // 8) cols = max(80, self.text.winfo_width() // 8)
rows = max(24, self.text.winfo_height() // 16) rows = max(24, self.text.winfo_height() // 16)
self.channel = self.ssh.invoke_shell( self.channel = self.ssh.invoke_shell(
@@ -1007,6 +1049,7 @@ class SessionDialog(tk.Toplevel):
("Порт:", "port", str(session.get("port", 22)) if session else "22"), ("Порт:", "port", str(session.get("port", 22)) if session else "22"),
("Пользователь:", "user", session.get("user", "") if session else "root"), ("Пользователь:", "user", session.get("user", "") if session else "root"),
("Пароль:", "password", ""), ("Пароль:", "password", ""),
("SSH ключ:", "key_file", session.get("key_file", "") if session else ""),
] ]
self.entries = {} self.entries = {}
@@ -1023,6 +1066,14 @@ class SessionDialog(tk.Toplevel):
setup_entry_clipboard(entry) setup_entry_clipboard(entry)
self.entries[key] = entry self.entries[key] = entry
# Browse button for SSH key file
key_entry = self.entries["key_file"]
browse_btn = tk.Button(frame, text="...", bg="#45475a", fg="#cdd6f4",
relief=tk.FLAT, font=("Consolas", 9), padx=4,
command=self._browse_key)
key_row = len(fields) - 1 # last field
browse_btn.grid(row=key_row, column=2, padx=4, pady=3)
row_group = len(fields) row_group = len(fields)
tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#cdd6f4", tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#cdd6f4",
font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3) font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3)
@@ -1053,6 +1104,16 @@ class SessionDialog(tk.Toplevel):
self.bind("<Escape>", lambda e: self.destroy()) self.bind("<Escape>", lambda e: self.destroy())
self.wait_window() self.wait_window()
def _browse_key(self):
path = filedialog.askopenfilename(
parent=self, title="SSH ключ",
initialdir=os.path.expanduser("~/.ssh"),
filetypes=[("All", "*"), ("PEM", "*.pem"), ("Key", "*.key")],
)
if path:
self.entries["key_file"].delete(0, tk.END)
self.entries["key_file"].insert(0, path)
def _ok(self): def _ok(self):
host = self.entries["host"].get().strip() host = self.entries["host"].get().strip()
if not host: if not host:
@@ -1070,145 +1131,12 @@ class SessionDialog(tk.Toplevel):
"port": port, "port": port,
"user": self.entries["user"].get().strip() or "root", "user": self.entries["user"].get().strip() or "root",
"password": self.entries["password"].get(), "password": self.entries["password"].get(),
"key_file": self.entries["key_file"].get().strip(),
"group": self.group_var.get().strip(), "group": self.group_var.get().strip(),
} }
self.destroy() self.destroy()
# ── AI Agent Settings Dialog ──────────────────────────────────
class AISettingsDialog(tk.Toplevel):
def __init__(self, parent, config):
super().__init__(parent)
self.title("Настройки AI-агента")
self.config = config
self.result = None
self.resizable(False, False)
self.configure(bg="#1e1e2e")
self.grab_set()
frame = tk.Frame(self, bg="#1e1e2e", padx=20, pady=20)
frame.pack(fill=tk.BOTH, expand=True)
tk.Label(frame, text="AI-провайдер", bg="#1e1e2e", fg="#cdd6f4",
font=("Consolas", 12, "bold")).pack(anchor="w", pady=(0, 10))
self.provider_var = tk.StringVar(value=config.get("ai_provider", "local"))
providers = [
("local", "Локальная база (без API)"),
("claude", "Claude API (Anthropic)"),
("deepseek", "DeepSeek API"),
]
for val, label in providers:
rb = tk.Radiobutton(
frame, text=label, variable=self.provider_var, value=val,
bg="#1e1e2e", fg="#cdd6f4", selectcolor="#313244",
activebackground="#1e1e2e", activeforeground="#cdd6f4",
font=("Consolas", 10),
)
rb.pack(anchor="w", pady=2)
tk.Frame(frame, bg="#45475a", height=1).pack(fill=tk.X, pady=10)
tk.Label(frame, text="Claude API Key:", bg="#1e1e2e", fg="#cdd6f4",
font=("Consolas", 10)).pack(anchor="w")
self.claude_key = tk.Entry(frame, width=50, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT,
font=("Consolas", 9), show="*")
self.claude_key.insert(0, config.get("claude_api_key", ""))
self.claude_key.pack(fill=tk.X, pady=(2, 8))
setup_entry_clipboard(self.claude_key)
tk.Label(frame, text="DeepSeek API Key:", bg="#1e1e2e", fg="#cdd6f4",
font=("Consolas", 10)).pack(anchor="w")
self.deepseek_key = tk.Entry(frame, width=50, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT,
font=("Consolas", 9), show="*")
self.deepseek_key.insert(0, config.get("deepseek_api_key", ""))
self.deepseek_key.pack(fill=tk.X, pady=(2, 12))
setup_entry_clipboard(self.deepseek_key)
btn_frame = tk.Frame(frame, bg="#1e1e2e")
btn_frame.pack()
tk.Button(btn_frame, text="Сохранить", bg="#89b4fa", fg="#1e1e2e",
activebackground="#6c8fff", relief=tk.FLAT,
font=("Consolas", 10, "bold"), padx=20, pady=4,
command=self._save).pack(side=tk.LEFT, padx=4)
tk.Button(btn_frame, text="Отмена", bg="#45475a", fg="#cdd6f4",
activebackground="#585b70", relief=tk.FLAT,
font=("Consolas", 10), padx=20, pady=4,
command=self.destroy).pack(side=tk.LEFT, padx=4)
self.wait_window()
def _save(self):
self.result = {
"ai_provider": self.provider_var.get(),
"claude_api_key": self.claude_key.get().strip(),
"deepseek_api_key": self.deepseek_key.get().strip(),
}
self.destroy()
# ── AI API Caller ─────────────────────────────────────────────
def _ssl_context():
"""Create SSL context — skip verify on Windows/frozen (often missing certs)."""
if sys.platform == "win32" or getattr(sys, "frozen", False):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None # use default on macOS/Linux
def call_claude_api(api_key, query):
url = "https://api.anthropic.com/v1/messages"
payload = json.dumps({
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"system": AI_SYSTEM_PROMPT,
"messages": [{"role": "user", "content": query}],
}).encode("utf-8")
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("x-api-key", api_key)
req.add_header("anthropic-version", "2023-06-01")
req.add_header("content-type", "application/json")
ctx = _ssl_context()
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
data = json.loads(resp.read().decode("utf-8"))
text = ""
for block in data.get("content", []):
if block.get("type") == "text":
text += block["text"]
return text
def call_deepseek_api(api_key, query):
url = "https://api.deepseek.com/v1/chat/completions"
payload = json.dumps({
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": AI_SYSTEM_PROMPT},
{"role": "user", "content": query},
],
"max_tokens": 1024,
}).encode("utf-8")
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Authorization", f"Bearer {api_key}")
req.add_header("Content-Type", "application/json")
ctx = _ssl_context()
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
data = json.loads(resp.read().decode("utf-8"))
choices = data.get("choices", [])
if choices:
return choices[0].get("message", {}).get("content", "")
return ""
# ── SFTP File Manager Widget ────────────────────────────────── # ── SFTP File Manager Widget ──────────────────────────────────
@@ -1266,10 +1194,11 @@ class FileManagerWidget(tk.Frame):
cols = ("size", "modified", "perms") cols = ("size", "modified", "perms")
self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings", self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings",
selectmode="browse") selectmode="browse")
self.tree.heading("#0", text="Имя") self.tree.heading("#0", text="Имя", command=lambda: self._sort_column("#0"))
self.tree.heading("size", text="Размер") self.tree.heading("size", text="Размер", command=lambda: self._sort_column("size"))
self.tree.heading("modified", text="Изменён") self.tree.heading("modified", text="Изменён", command=lambda: self._sort_column("modified"))
self.tree.heading("perms", text="Права") self.tree.heading("perms", text="Права", command=lambda: self._sort_column("perms"))
self._sort_reverse = False
self.tree.column("#0", width=250, minwidth=150) self.tree.column("#0", width=250, minwidth=150)
self.tree.column("size", width=80, minwidth=60) self.tree.column("size", width=80, minwidth=60)
self.tree.column("modified", width=140, minwidth=100) self.tree.column("modified", width=140, minwidth=100)
@@ -1354,6 +1283,15 @@ class FileManagerWidget(tk.Frame):
n /= 1024 n /= 1024
return f"{n:.1f} ТБ" return f"{n:.1f} ТБ"
def _sort_column(self, col):
"""Sort tree by column, toggle ascending/descending."""
items = [(self.tree.set(k, col) if col != "#0" else self.tree.item(k, "text"),
k) for k in self.tree.get_children("")]
self._sort_reverse = not self._sort_reverse
items.sort(key=lambda t: t[0].lower(), reverse=self._sort_reverse)
for idx, (_, k) in enumerate(items):
self.tree.move(k, "", idx)
def _get_selected_name(self): def _get_selected_name(self):
sel = self.tree.selection() sel = self.tree.selection()
if not sel: if not sel:
@@ -1801,40 +1739,15 @@ class App(tk.Tk):
canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="") canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="")
def _build_ui(self): def _build_ui(self):
# ── Top: PCA brand bar ── # ── Top: minimal title bar (Termius style) ──
top_bar = tk.Frame(self, bg="#181825", height=48) top_bar = tk.Frame(self, bg="#14141e", height=40)
top_bar.pack(fill=tk.X, side=tk.TOP) top_bar.pack(fill=tk.X, side=tk.TOP)
top_bar.pack_propagate(False) top_bar.pack_propagate(False)
logo_canvas = tk.Canvas(top_bar, width=36, height=36, bg="#181825", highlightthickness=0) tk.Label(top_bar, text=f"{APP_NAME}", bg="#14141e", fg="#e0e0e0",
logo_canvas.pack(side=tk.LEFT, padx=(10, 6), pady=6) font=("Helvetica", 13, "bold")).pack(side=tk.LEFT, padx=12, pady=6)
self._draw_pca_logo(logo_canvas, 18, 16, 30) tk.Label(top_bar, text=f"v{VERSION}", bg="#14141e", fg="#555",
font=("Helvetica", 10)).pack(side=tk.LEFT, pady=6)
brand_frame = tk.Frame(top_bar, bg="#181825")
brand_frame.pack(side=tk.LEFT, pady=4)
tk.Label(brand_frame, text=APP_NAME, bg="#181825", fg="#cdd6f4",
font=("Consolas", 13, "bold")).pack(anchor="w")
tk.Label(brand_frame, text=APP_SUBTITLE, bg="#181825", fg="#6c7086",
font=("Consolas", 9)).pack(anchor="w")
tk.Frame(top_bar, bg="#45475a", width=1).pack(side=tk.LEFT, fill=tk.Y, padx=12, pady=8)
links = [
("GitHub", "https://github.com/nickolay-frolov"),
("Boosty", "https://boosty.to/lot_andrey"),
("Telegram", "https://t.me/lot_andrey"),
("Поддержать (СБП)", "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"),
]
for i, (text, url) in enumerate(links):
if i > 0:
tk.Label(top_bar, text="·", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
lnk = tk.Label(top_bar, text=text, bg="#181825", fg="#89b4fa",
font=("Consolas", 9, "underline"), cursor="hand2")
lnk.pack(side=tk.LEFT, padx=6)
lnk.bind("<Button-1>", lambda e, u=url: self._open_url(u))
tk.Label(top_bar, text="@lot_andrey", bg="#181825", fg="#6c7086",
font=("Consolas", 9)).pack(side=tk.RIGHT, padx=10)
# ── Main layout ── # ── Main layout ──
main_frame = tk.Frame(self, bg="#1e1e2e") main_frame = tk.Frame(self, bg="#1e1e2e")
@@ -1855,17 +1768,18 @@ class App(tk.Tk):
# Welcome tab # Welcome tab
welcome = tk.Frame(self.notebook, bg="#1e1e2e") welcome = tk.Frame(self.notebook, bg="#1e1e2e")
self.notebook.add(welcome, text="Добро пожаловать") self.notebook.add(welcome, text="Welcome")
tk.Label( tk.Label(
welcome, welcome,
text=f"{APP_NAME} v{VERSION}\n\n" text=f"{APP_NAME} v{VERSION}\n\n"
"• Двойной клик по карточке = подключение\n" "Double-click a card to connect\n"
"• Быстрое подключение внизу слева\n" "Quick connect panel at bottom left\n"
"Все сессии сохраняются автоматически\n" "Sessions auto-saved\n"
"• Ctrl+C/D/L работают в терминале\n" "SSH key auth supported\n"
"• AI-агент справа — спроси что угодно\n" "SFTP file manager per connection\n"
"• Панель команд можно скрыть/показать", "Export/Import sessions via toolbar\n"
font=("Consolas", 12), "Cmd+/- to zoom",
font=("Helvetica", 12),
justify=tk.CENTER, justify=tk.CENTER,
bg="#1e1e2e", bg="#1e1e2e",
fg="#cdd6f4", fg="#cdd6f4",
@@ -1892,6 +1806,8 @@ class App(tk.Tk):
tk.Button(toolbar, text=" Группа", command=self._new_group, **btn_style).pack(side=tk.LEFT, padx=2, pady=3) tk.Button(toolbar, text=" Группа", command=self._new_group, **btn_style).pack(side=tk.LEFT, padx=2, pady=3)
tk.Button(toolbar, text="", command=self._edit_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3) tk.Button(toolbar, text="", command=self._edit_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
tk.Button(toolbar, text="", command=self._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3) tk.Button(toolbar, text="", command=self._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
tk.Button(toolbar, text="", command=self._export_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=1, pady=3)
tk.Button(toolbar, text="", command=self._import_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=1, pady=3)
# Scrollable card area # Scrollable card area
self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0) self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0)
@@ -2146,6 +2062,7 @@ class App(tk.Tk):
move_menu.add_command(label=g, command=lambda g=g: self._move_and_refresh(index, g)) move_menu.add_command(label=g, command=lambda g=g: self._move_and_refresh(index, g))
menu.add_cascade(label="Переместить в...", menu=move_menu) menu.add_cascade(label="Переместить в...", menu=move_menu)
menu.add_separator() menu.add_separator()
menu.add_command(label="Дублировать", command=lambda: self._duplicate_session(index))
menu.add_command(label="Изменить", command=lambda: self._edit_session_by_idx(index)) menu.add_command(label="Изменить", command=lambda: self._edit_session_by_idx(index))
menu.add_command(label="Удалить", command=lambda: self._delete_session_by_idx(index)) menu.add_command(label="Удалить", command=lambda: self._delete_session_by_idx(index))
@@ -2216,29 +2133,16 @@ class App(tk.Tk):
self._populate_commands() self._populate_commands()
# ── Agent Panel ── # ── Agent Panel (local knowledge base) ──
agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e") agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e")
self._right_paned.add(agent_wrapper, minsize=120) self._right_paned.add(agent_wrapper, minsize=120)
agent_header = tk.Frame(agent_wrapper, bg="#181825") agent_header = tk.Frame(agent_wrapper, bg="#181825")
agent_header.pack(fill=tk.X) agent_header.pack(fill=tk.X)
tk.Label(agent_header, text="AI Агент", bg="#181825", fg="#a6e3a1", tk.Label(agent_header, text="Поиск команд", bg="#181825", fg="#a6e3a1",
font=("Consolas", 11, "bold")).pack(side=tk.LEFT, padx=6, pady=4) font=("Consolas", 11, "bold")).pack(side=tk.LEFT, padx=6, pady=4)
# Provider indicator
provider = self.config.get("ai_provider", "local")
provider_names = {"local": "Локальный", "claude": "Claude", "deepseek": "DeepSeek"}
self._provider_label = tk.Label(
agent_header, text=f"[{provider_names.get(provider, provider)}]",
bg="#181825", fg="#6c7086", font=("Consolas", 8),
)
self._provider_label.pack(side=tk.LEFT, padx=2)
tk.Button(agent_header, text="", bg="#181825", fg="#6c7086",
activebackground="#181825", relief=tk.FLAT, font=("Consolas", 11),
cursor="hand2", command=self._ai_settings).pack(side=tk.RIGHT, padx=6)
# Agent content # Agent content
self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e") self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e")
self.agent_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2) self.agent_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2)
@@ -2295,40 +2199,19 @@ class App(tk.Tk):
self._right_paned.forget(self.commands_container) self._right_paned.forget(self.commands_container)
self._toggle_btn.configure(text="Команды ▶") self._toggle_btn.configure(text="Команды ▶")
# ── AI Agent ────────────────────────────────────────────── # ── Command Search (local knowledge base) ─────────────────
def _ai_settings(self):
dlg = AISettingsDialog(self, self.config.data)
if dlg.result:
for k, v in dlg.result.items():
self.config.set(k, v)
provider = self.config.get("ai_provider", "local")
provider_names = {"local": "Локальный", "claude": "Claude", "deepseek": "DeepSeek"}
self._provider_label.configure(text=f"[{provider_names.get(provider, provider)}]")
def _agent_ask(self): def _agent_ask(self):
query = self.agent_entry.get().strip() query = self.agent_entry.get().strip()
if not query: if not query:
return return
provider = self.config.get("ai_provider", "local")
if provider == "local":
self._agent_ask_local(query)
elif provider in ("claude", "deepseek"):
self._agent_ask_api(query, provider)
def _agent_ask_local(self, query):
results = agent_search(query) results = agent_search(query)
self.agent_result.configure(state=tk.NORMAL) self.agent_result.configure(state=tk.NORMAL)
self.agent_result.delete("1.0", tk.END) self.agent_result.delete("1.0", tk.END)
if not results: if not results:
self.agent_result.insert(tk.END, "Не нашёл команд. Попробуй другие слова:\n", "desc") self.agent_result.insert(tk.END, "Ничего не найдено. Попробуй:\n", "desc")
self.agent_result.insert(tk.END, "обновить, диск, память, порт, пароль,\n", "hint") self.agent_result.insert(tk.END, "обновить, диск, память, порт, пароль,\n", "hint")
self.agent_result.insert(tk.END, "docker, nginx, ssl, vpn, wifi, dhcp\n\n", "hint") self.agent_result.insert(tk.END, "docker, nginx, ssl, vpn, wifi, dhcp\n", "hint")
self.agent_result.insert(tk.END, "Совет: ", "desc")
self.agent_result.insert(tk.END, "подключи Claude или DeepSeek API\n", "ai")
self.agent_result.insert(tk.END, "для умного ассистента (⚙ настройки)", "ai")
else: else:
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc") self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
for _, platform, cmd, desc in results: for _, platform, cmd, desc in results:
@@ -2337,82 +2220,6 @@ class App(tk.Tk):
self.agent_result.insert(tk.END, f" {desc}\n", "desc") self.agent_result.insert(tk.END, f" {desc}\n", "desc")
self.agent_result.configure(state=tk.DISABLED) self.agent_result.configure(state=tk.DISABLED)
def _agent_ask_api(self, query, provider):
self.agent_result.configure(state=tk.NORMAL)
self.agent_result.delete("1.0", tk.END)
self.agent_result.insert(tk.END, "Думаю...\n", "thinking")
self.agent_result.configure(state=tk.DISABLED)
def _call():
try:
if provider == "claude":
api_key = self.config.get("claude_api_key", "")
if not api_key:
return None, "API-ключ Claude не задан. Нажми ⚙"
response = call_claude_api(api_key, query)
elif provider == "deepseek":
api_key = self.config.get("deepseek_api_key", "")
if not api_key:
return None, "API-ключ DeepSeek не задан. Нажми ⚙"
response = call_deepseek_api(api_key, query)
else:
return None, "Неизвестный провайдер"
return response, None
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:200]
return None, f"HTTP {e.code}: {body}"
except Exception as e:
return None, str(e)
def _on_result(response, error):
self.agent_result.configure(state=tk.NORMAL)
self.agent_result.delete("1.0", tk.END)
if error:
self.agent_result.insert(tk.END, f"Ошибка: {error}\n", "error")
else:
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
# Parse response for commands
lines = response.strip().split("\n")
for line in lines:
line = line.strip()
if not line:
continue
# Try to detect command lines: starts with [linux]/[keenetic] or contains command pattern
if line.startswith("[linux]") or line.startswith("[keenetic]"):
parts = line.split("] ", 1)
platform = parts[0] + "]"
rest = parts[1] if len(parts) > 1 else ""
if "" in rest:
cmd, desc = rest.split("", 1)
self.agent_result.insert(tk.END, f"{platform} ", "platform")
self.agent_result.insert(tk.END, f"{cmd.strip()}\n", "cmd")
self.agent_result.insert(tk.END, f" {desc.strip()}\n", "desc")
else:
self.agent_result.insert(tk.END, f"{platform} ", "platform")
self.agent_result.insert(tk.END, f"{rest}\n", "cmd")
elif line.startswith("`") or line.startswith("$") or line.startswith("sudo ") or line.startswith("show "):
# Looks like a command
cmd = line.strip("`").strip("$ ")
self.agent_result.insert(tk.END, f"{cmd}\n", "cmd")
elif "" in line or " - " in line:
sep = "" if "" in line else " - "
cmd, desc = line.split(sep, 1)
cmd = cmd.strip().strip("`").strip("$ ")
if cmd:
self.agent_result.insert(tk.END, f"{cmd}\n", "cmd")
self.agent_result.insert(tk.END, f" {desc.strip()}\n", "desc")
else:
self.agent_result.insert(tk.END, f"{line}\n", "ai")
else:
self.agent_result.insert(tk.END, f"{line}\n", "ai")
self.agent_result.configure(state=tk.DISABLED)
def _thread():
response, error = _call()
self.after(0, lambda: _on_result(response, error))
threading.Thread(target=_thread, daemon=True).start()
def _agent_send_cmd(self, event): def _agent_send_cmd(self, event):
idx = self.agent_result.index(f"@{event.x},{event.y}") idx = self.agent_result.index(f"@{event.x},{event.y}")
line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip() line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip()
@@ -2491,7 +2298,8 @@ class App(tk.Tk):
def _connect_by_idx(self, idx): def _connect_by_idx(self, idx):
s = self.store.sessions[idx] s = self.store.sessions[idx]
pw = self.store.get_password(s) pw = self.store.get_password(s)
self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"]) self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"],
key_file=s.get("key_file"))
def _new_session(self): def _new_session(self):
pregroup = "" pregroup = ""
@@ -2502,8 +2310,11 @@ class App(tk.Tk):
groups=self.store.groups) groups=self.store.groups)
if dlg.result: if dlg.result:
r = dlg.result r = dlg.result
self.store.add(r["host"], r["port"], r["user"], r["password"], s = self.store.add(r["host"], r["port"], r["user"], r["password"],
r["name"], r.get("group", "")) r["name"], r.get("group", ""))
if r.get("key_file"):
idx = len(self.store.sessions) - 1
self.store.update(idx, key_file=r["key_file"])
self._refresh_cards() self._refresh_cards()
def _edit_session(self): def _edit_session(self):
@@ -2518,7 +2329,8 @@ class App(tk.Tk):
r = dlg.result r = dlg.result
update = {"name": r["name"], "description": r.get("description", ""), update = {"name": r["name"], "description": r.get("description", ""),
"host": r["host"], "port": r["port"], "host": r["host"], "port": r["port"],
"user": r["user"], "group": r.get("group", "")} "user": r["user"], "group": r.get("group", ""),
"key_file": r.get("key_file", "")}
if r["password"]: if r["password"]:
update["password"] = r["password"] update["password"] = r["password"]
self.store.update(idx, **update) self.store.update(idx, **update)
@@ -2535,6 +2347,35 @@ class App(tk.Tk):
self._selected_card_idx = None self._selected_card_idx = None
self._refresh_cards() self._refresh_cards()
def _duplicate_session(self, idx):
self.store.duplicate(idx)
self._refresh_cards()
def _export_sessions(self):
path = filedialog.asksaveasfilename(
parent=self, title="Экспорт сессий",
defaultextension=".json", filetypes=[("JSON", "*.json")],
initialfile="pca_sessions.json")
if path:
data = self.store.export_json()
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
messagebox.showinfo("Экспорт", f"Экспортировано {len(data['sessions'])} сессий", parent=self)
def _import_sessions(self):
path = filedialog.askopenfilename(
parent=self, title="Импорт сессий",
filetypes=[("JSON", "*.json")])
if path:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
self.store.import_json(data)
self._refresh_cards()
messagebox.showinfo("Импорт", "Сессии импортированы", parent=self)
except Exception as e:
messagebox.showerror("Ошибка импорта", str(e), parent=self)
def _new_group(self): def _new_group(self):
name = simpledialog.askstring("Новая группа", "Название группы:", parent=self) name = simpledialog.askstring("Новая группа", "Название группы:", parent=self)
if name and name.strip(): if name and name.strip():
@@ -2570,27 +2411,31 @@ class App(tk.Tk):
self._open_terminal(host, port, user, pw) self._open_terminal(host, port, user, pw)
self.q_pass.delete(0, tk.END) self.q_pass.delete(0, tk.END)
def _open_terminal(self, host, port, user, password, name=None): def _open_terminal(self, host, port, user, password, name=None, key_file=None):
tab_name = name or f"{user}@{host}" tab_name = name or f"{user}@{host}"
term = TerminalWidget(self.notebook, on_close=lambda: None) term = TerminalWidget(self.notebook, on_close=lambda: None)
self.notebook.add(term, text=f"{tab_name}") self.notebook.add(term, text=f"{tab_name}")
self.notebook.select(term) self.notebook.select(term)
term.connect(host, port, user, password) term.connect(host, port, user, password, key_file=key_file)
# Store connection info for file manager # Store connection info for file manager and reconnect
term._conn_info = {"host": host, "port": port, "user": user, "password": password} term._conn_info = {"host": host, "port": port, "user": user,
"password": password, "key_file": key_file}
close_btn_frame = tk.Frame(term, bg="#1e1e2e") close_btn_frame = tk.Frame(term, bg="#1e1e2e")
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a", btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a",
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 6, "pady": 2} "relief": tk.FLAT, "font": ("Consolas", 9), "padx": 6, "pady": 2}
tk.Button(close_btn_frame, text="📁 Файлы", tk.Button(close_btn_frame, text="SFTP",
command=lambda: self._open_file_manager(term), command=lambda: self._open_file_manager(term),
**btn_s).pack(side=tk.LEFT, padx=4, pady=2) **btn_s).pack(side=tk.LEFT, padx=4, pady=2)
tk.Button(close_btn_frame, text="Reconnect",
command=lambda: self._reconnect_terminal(term),
**btn_s).pack(side=tk.LEFT, padx=4, pady=2)
tk.Button( tk.Button(
close_btn_frame, text="✕ Закрыть", close_btn_frame, text="Close",
bg="#313244", fg="#cdd6f4", activebackground="#45475a", bg="#313244", fg="#cdd6f4", activebackground="#45475a",
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2, relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2,
command=lambda: self._close_tab(term), command=lambda: self._close_tab(term),
@@ -2618,17 +2463,37 @@ class App(tk.Tk):
self.notebook.add(fm, text=tab_name) self.notebook.add(fm, text=tab_name)
self.notebook.select(fm) self.notebook.select(fm)
# Connect in background # Connect in background (with key auth support)
def _connect(): def _connect():
try: try:
ssh2.connect(info["host"], port=info["port"], connect_kw = dict(hostname=info["host"], port=info["port"],
username=info["user"], password=info["password"], username=info["user"], timeout=10)
timeout=10, look_for_keys=False, allow_agent=False) key_file = info.get("key_file")
if key_file and os.path.isfile(key_file):
connect_kw["key_filename"] = key_file
connect_kw["look_for_keys"] = False
connect_kw["allow_agent"] = False
if info.get("password"):
connect_kw["passphrase"] = info["password"]
else:
connect_kw["password"] = info["password"]
connect_kw["look_for_keys"] = False
connect_kw["allow_agent"] = False
ssh2.connect(**connect_kw)
fm.after(0, fm._connect_sftp) fm.after(0, fm._connect_sftp)
except Exception as e: except Exception as e:
fm.after(0, lambda: fm.status_var.set(f"Ошибка SSH: {e}")) fm.after(0, lambda: fm.status_var.set(f"Ошибка SSH: {e}"))
threading.Thread(target=_connect, daemon=True).start() threading.Thread(target=_connect, daemon=True).start()
def _reconnect_terminal(self, term):
info = getattr(term, "_conn_info", None)
if not info:
return
term.disconnect()
term._write("\n--- Переподключение ---\n", "info")
term.connect(info["host"], info["port"], info["user"], info["password"],
key_file=info.get("key_file"))
def _close_tab(self, term): def _close_tab(self, term):
term.disconnect() term.disconnect()
self.notebook.forget(term) self.notebook.forget(term)