887 lines
36 KiB
Python
887 lines
36 KiB
Python
"""Hermes Hub — Native Windows Desktop Application (Stabilization & UX v3).
|
||
|
||
Features:
|
||
- Windows AppUserModelID (HermesHub.Desktop) for proper Taskbar Pinning & Identity
|
||
- High-Performance Sub-100ms Tab Switching (Persistent View Cache, Zero I/O on switch)
|
||
- Instrumentation: tab_switch_ms Logging & Benchmark Verification
|
||
- Debounced Resize & Movement Handling
|
||
- Unified Health Presentation (Strict Priority Resolver, No Stale Quota on Unadded Accounts)
|
||
- Real Provider Icon Marks (Google Antigravity, OpenAI Codex, OpenCode Go)
|
||
- Non-blocking Background Workers & Graceful Shutdown Coordinator
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
import webbrowser
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import customtkinter as ctk
|
||
|
||
# ── Set Windows AppUserModelID before UI initialization ──
|
||
if sys.platform == "win32":
|
||
try:
|
||
APP_ID = "HermesHub.Desktop"
|
||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Ensure plugin and repo paths are on sys.path ──
|
||
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
|
||
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
|
||
_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent"
|
||
for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
|
||
_ps = str(_p)
|
||
if _p.exists() and _ps not in sys.path:
|
||
sys.path.insert(0, _ps)
|
||
|
||
from antigravity_provider.router.router_config import load_router_config
|
||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||
from antigravity_provider.router.adapters import get_adapter
|
||
from antigravity_provider import paths
|
||
from antigravity_provider.version import __version__
|
||
|
||
from antigravity_provider.router.ui.theme import Theme
|
||
from antigravity_provider.router.ui.assets import AssetManager
|
||
from antigravity_provider.router.ui.components import HubButton, HubModal
|
||
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
|
||
|
||
from antigravity_provider.router.unified_health import (
|
||
UnifiedHealthService,
|
||
EventLogService,
|
||
SystemReadiness,
|
||
STATUS_HEALTHY,
|
||
)
|
||
|
||
from antigravity_provider.router.ui.views.team_view import TeamView
|
||
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
|
||
from antigravity_provider.router.ui.views.accounts_view import AccountsView
|
||
from antigravity_provider.router.ui.views.providers_view import ProvidersView
|
||
from antigravity_provider.router.ui.views.routing_view import RoutingView
|
||
from antigravity_provider.router.ui.views.health_view import HealthView
|
||
from antigravity_provider.router.ui.views.logs_view import LogsView
|
||
from antigravity_provider.router.ui.views.settings_view import SettingsView
|
||
from antigravity_provider.router.ui.views.about_view import AboutView
|
||
from antigravity_provider.router.ui.views.analytics_view import AnalyticsView
|
||
from antigravity_provider.router.ui.views.quotas_view import QuotasView
|
||
|
||
logger = logging.getLogger("hermes.hub.gui")
|
||
|
||
|
||
def _load_saved_theme() -> str:
|
||
settings_file = paths.get_hermes_home() / "hub_settings.json"
|
||
try:
|
||
settings = json.loads(settings_file.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError, TypeError):
|
||
return "dark"
|
||
return str(settings.get("theme", "dark"))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Actions Layer (Safe & Non-blocking)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
|
||
if ok:
|
||
EventLogService.get().log(
|
||
"account", f"Профиль {profile_id} назначен основным аккаунтом Hermes ({provider}).", level="info"
|
||
)
|
||
return ok, msg
|
||
|
||
|
||
def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||
ok, msg = AutoAssigner.set_primary_orchestrator(profile_id)
|
||
if ok:
|
||
EventLogService.get().log(
|
||
"routing", f"Профиль {profile_id} назначен главным оркестратором команды.", level="info"
|
||
)
|
||
return ok, msg
|
||
|
||
|
||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||
"""Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers."""
|
||
config = load_router_config()
|
||
pcfg = config.get_profile(profile_id)
|
||
if not pcfg:
|
||
return {"success": False, "error": f"Профиль '{profile_id}' не найден"}
|
||
|
||
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||
if not status.get("authenticated"):
|
||
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
|
||
|
||
adapter = get_adapter(pcfg.provider)
|
||
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
||
t0 = time.time()
|
||
try:
|
||
resp = adapter.invoke(
|
||
pcfg,
|
||
{
|
||
"model": model,
|
||
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
|
||
"temperature": 0.1,
|
||
},
|
||
)
|
||
el = round(time.time() - t0, 2)
|
||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success")
|
||
return {"success": True, "model": model, "duration_sec": el, "response": content[:120]}
|
||
except Exception as e:
|
||
EventLogService.get().log("system", f"Ошибка теста {profile_id} ({model}): {e}", level="error")
|
||
return {"success": False, "model": model, "duration_sec": round(time.time() - t0, 2), "error": str(e)}
|
||
|
||
|
||
def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||
auth_p = ProfileAuthManager.get_profile_dir(provider, profile_id) / "auth.json"
|
||
if auth_p.is_file():
|
||
try:
|
||
auth_p.unlink()
|
||
EventLogService.get().log("account", f"Учетные данные для {profile_id} удалены.", level="warning")
|
||
return True, f"Учетные данные для '{profile_id}' удалены"
|
||
except Exception as e:
|
||
return False, f"Ошибка удаления: {e}"
|
||
return True, "Учетные данные отсутствовали"
|
||
|
||
|
||
def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
|
||
settings_file = paths.get_hermes_home() / "hub_settings.json"
|
||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||
existing: Dict[str, Any] = {}
|
||
if settings_file.exists():
|
||
try:
|
||
existing = json.loads(settings_file.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
existing = {}
|
||
existing.update(settings)
|
||
temp_file = settings_file.with_suffix(".json.tmp")
|
||
temp_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
os.replace(temp_file, settings_file)
|
||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||
|
||
AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300)))
|
||
return True, "Настройки сохранены"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Main Application Window
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class HermesHubApp(ctk.CTk):
|
||
def __init__(self):
|
||
self._theme_name = Theme.apply_scheme(_load_saved_theme())
|
||
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
|
||
super().__init__()
|
||
self.title("Hermes Hub")
|
||
self.geometry("1380x880")
|
||
self.minsize(1100, 700)
|
||
|
||
ctk.set_default_color_theme("blue")
|
||
self.configure(fg_color=Theme.BG_WINDOW)
|
||
|
||
# Set Windows Multi-Resolution Icon
|
||
ico_path = AssetManager.get().get_ico_path()
|
||
if ico_path and os.path.exists(ico_path):
|
||
try:
|
||
self.iconbitmap(ico_path)
|
||
except Exception:
|
||
pass
|
||
|
||
self._current_view = "overview"
|
||
self._views: Dict[str, ctk.CTkFrame] = {}
|
||
self._view_generations: Dict[str, int] = {}
|
||
self._shutting_down = False
|
||
self._resize_timer_id = None
|
||
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
self.bind("<Configure>", self._on_window_configure)
|
||
|
||
self._build_layout()
|
||
self._show_view("overview")
|
||
|
||
try:
|
||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||
|
||
HermesRefreshScheduler.get().start()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||
|
||
AccountQuotaService.get().start_background_scheduler()
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(50, self._refresh_data)
|
||
|
||
def _build_layout(self):
|
||
# ── Sidebar (Left) ──
|
||
self.sidebar = ctk.CTkFrame(self, width=Theme.WIDTH_SIDEBAR, fg_color=Theme.BG_SIDEBAR, corner_radius=0)
|
||
self.sidebar.pack(side="left", fill="y")
|
||
self.sidebar.pack_propagate(False)
|
||
|
||
# Top Centered Brand Logo
|
||
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
||
brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM))
|
||
|
||
logo_img = AssetManager.get().get_logo_image(size=(78, 78))
|
||
if logo_img:
|
||
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
||
logo_lbl.pack(anchor="center", pady=(0, 6))
|
||
|
||
ctk.CTkLabel(
|
||
brand_container,
|
||
text="HERMES HUB",
|
||
font=(Theme.FONT_FAMILY_TITLE, 17, "bold"),
|
||
text_color=Theme.TEXT_ACCENT,
|
||
).pack(anchor="center")
|
||
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack(
|
||
fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM)
|
||
)
|
||
|
||
# Nav Items with clean Fluent glyphs
|
||
self._nav_items = [
|
||
("overview", "Обзор", "overview"),
|
||
("team", "Команда", "team"),
|
||
("accounts", "Аккаунты", "accounts"),
|
||
("routing", "Маршрутизация", "routing"),
|
||
("providers", "Провайдеры", "providers"),
|
||
("quotas", "Квоты и лимиты", "quotas"),
|
||
("analytics", "Аналитика", "analytics"),
|
||
("health", "Состояние", "health"),
|
||
("logs", "Журнал событий", "logs"),
|
||
("settings", "Настройки", "settings"),
|
||
("about", "О программе", "about"),
|
||
]
|
||
|
||
self.nav_frame = ctk.CTkScrollableFrame(
|
||
self.sidebar,
|
||
fg_color="transparent",
|
||
corner_radius=0,
|
||
scrollbar_fg_color=Theme.BG_SIDEBAR,
|
||
scrollbar_button_color=Theme.BG_SIDEBAR,
|
||
scrollbar_button_hover_color=Theme.BORDER_HOVER,
|
||
)
|
||
self.nav_frame.pack(fill="both", expand=True)
|
||
self._nav_buttons: Dict[str, ctk.CTkButton] = {}
|
||
for key, label, icon in self._nav_items:
|
||
icon_image = AssetManager.get().get_nav_icon(icon, size=19)
|
||
btn = ctk.CTkButton(
|
||
self.nav_frame,
|
||
text=label,
|
||
image=icon_image,
|
||
compound="left",
|
||
font=Theme.font_body(),
|
||
height=Theme.HEIGHT_NAV_ITEM,
|
||
fg_color="transparent",
|
||
hover_color=Theme.SIDEBAR_HOVER,
|
||
text_color=Theme.SIDEBAR_TEXT,
|
||
anchor="w",
|
||
corner_radius=Theme.RADIUS_SM,
|
||
command=lambda k=key: self._show_view(k),
|
||
)
|
||
btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1)
|
||
self._nav_buttons[key] = btn
|
||
|
||
self.sidebar_version = ctk.CTkLabel(
|
||
self.sidebar,
|
||
text=f"Hermes Hub v{__version__}",
|
||
font=Theme.font_micro(),
|
||
text_color=Theme.SIDEBAR_MUTED,
|
||
)
|
||
self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM))
|
||
user_card = ctk.CTkFrame(
|
||
self.sidebar,
|
||
fg_color=Theme.SIDEBAR_SELECTED,
|
||
border_width=1,
|
||
border_color=Theme.BORDER,
|
||
corner_radius=Theme.RADIUS_MD,
|
||
)
|
||
user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||
ctk.CTkLabel(
|
||
user_card,
|
||
text="AD",
|
||
width=30,
|
||
height=30,
|
||
corner_radius=15,
|
||
fg_color=Theme.ACCENT,
|
||
text_color=Theme.TEXT_ON_ACCENT,
|
||
font=Theme.font_badge_bold(),
|
||
).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||
ctk.CTkLabel(
|
||
user_card,
|
||
text="Administrator\nОсновная команда",
|
||
justify="left",
|
||
font=Theme.font_micro(),
|
||
text_color=Theme.SIDEBAR_TEXT,
|
||
).pack(side="left")
|
||
|
||
# ── Global top bar ──
|
||
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0)
|
||
self.statusbar.pack(side="top", fill="x")
|
||
self.statusbar.pack_propagate(False)
|
||
|
||
self.status_left = ctk.CTkLabel(
|
||
self.statusbar,
|
||
text="● Состояние загружается",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.STATUS_HEALTHY,
|
||
)
|
||
self.status_left.pack(side="left", padx=Theme.SPACE_LG)
|
||
|
||
self.global_search = ctk.CTkEntry(
|
||
self.statusbar,
|
||
placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K",
|
||
width=360,
|
||
height=Theme.HEIGHT_INPUT,
|
||
fg_color=Theme.SURFACE,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.global_search.pack(side="left", padx=Theme.SPACE_MD)
|
||
self.global_search.bind("<Return>", self._run_global_search)
|
||
self.bind_all("<Control-k>", self._focus_global_search)
|
||
|
||
for icon_name, command in (
|
||
("settings", lambda: self._show_view("settings")),
|
||
("about", lambda: self._show_view("about")),
|
||
("logs", lambda: self._show_view("logs")),
|
||
):
|
||
HubButton(
|
||
self.statusbar,
|
||
text="",
|
||
image=AssetManager.get().get_nav_icon(icon_name, size=18),
|
||
variant="ghost",
|
||
width=Theme.HEIGHT_BTN_MD,
|
||
command=command,
|
||
).pack(side="right", padx=Theme.SPACE_XS)
|
||
self.add_account_button = HubButton(
|
||
self.statusbar,
|
||
text="+ Добавить аккаунт",
|
||
variant="primary",
|
||
command=lambda: self._handle_action("add_account", {}),
|
||
)
|
||
self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_MD))
|
||
|
||
self.status_right = ctk.CTkLabel(
|
||
self.statusbar,
|
||
text="Snapshot: Н/Д",
|
||
font=Theme.font_micro(),
|
||
text_color=Theme.TEXT_MUTED,
|
||
)
|
||
|
||
# ── Main Content Area ──
|
||
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
||
self.content.pack(side="right", fill="both", expand=True)
|
||
|
||
# Pre-instantiate all views so switching is 100% instant (0-15 ms)
|
||
for key, _, _ in self._nav_items:
|
||
self._views[key] = self._create_view(key)
|
||
|
||
def _focus_global_search(self, _event=None) -> str:
|
||
self.global_search.focus_set()
|
||
return "break"
|
||
|
||
def _run_global_search(self, _event=None) -> str:
|
||
query = self.global_search.get().strip()
|
||
self._show_view("accounts")
|
||
accounts = self._views.get("accounts")
|
||
if accounts and hasattr(accounts, "search"):
|
||
accounts.search.delete(0, "end")
|
||
accounts.search.insert(0, query)
|
||
accounts._set_search(query)
|
||
return "break"
|
||
|
||
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
||
"""Create view widget instance."""
|
||
if view_name == "overview":
|
||
return DashboardView(
|
||
self.content,
|
||
app_state={},
|
||
on_navigate=self._show_view,
|
||
on_action=self._handle_action,
|
||
)
|
||
elif view_name == "team":
|
||
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||
elif view_name == "accounts":
|
||
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
||
elif view_name == "providers":
|
||
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
||
elif view_name == "routing":
|
||
return RoutingView(self.content, on_action=self._handle_action)
|
||
elif view_name == "quotas":
|
||
return QuotasView(self.content)
|
||
elif view_name == "analytics":
|
||
return AnalyticsView(self.content)
|
||
elif view_name == "health":
|
||
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
||
elif view_name == "logs":
|
||
return LogsView(self.content)
|
||
elif view_name == "settings":
|
||
return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name)
|
||
elif view_name == "about":
|
||
return AboutView(self.content)
|
||
else:
|
||
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||
|
||
def _show_view(self, view_name: str):
|
||
"""Instant view switching using pack_forget() and cached widgets with lazy generation update."""
|
||
t0 = time.time()
|
||
prev_view = self._current_view
|
||
self._current_view = view_name
|
||
|
||
# Update sidebar button states
|
||
for key, btn in self._nav_buttons.items():
|
||
if key == view_name:
|
||
btn.configure(
|
||
fg_color=Theme.SIDEBAR_SELECTED,
|
||
text_color=Theme.TEXT_ACCENT,
|
||
border_width=1,
|
||
border_color=Theme.BORDER_ACCENT,
|
||
)
|
||
else:
|
||
btn.configure(
|
||
fg_color="transparent",
|
||
text_color=Theme.SIDEBAR_TEXT,
|
||
border_width=0,
|
||
)
|
||
|
||
# Hide currently active views
|
||
for v in self._views.values():
|
||
v.pack_forget()
|
||
|
||
# Show target view instantly
|
||
target_view = self._views.get(view_name)
|
||
if target_view:
|
||
target_view.pack(fill="both", expand=True)
|
||
|
||
# Lazy update if view state is behind current snapshot generation
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
|
||
snap = HubStateStore.get().get_snapshot()
|
||
if self._view_generations.get(view_name, 0) < snap.generation:
|
||
if hasattr(target_view, "update_data"):
|
||
try:
|
||
target_view.update_data(snap)
|
||
except Exception as ex:
|
||
logger.warning("Error in lazy view update for %s: %s", view_name, ex)
|
||
self._view_generations[view_name] = snap.generation
|
||
self._update_auxiliary_data(target_view)
|
||
|
||
# Instrument tab switch latency
|
||
el_ms = round((time.time() - t0) * 1000, 2)
|
||
if el_ms > 100:
|
||
logger.warning(f"[TAB SWITCH SLOW] {prev_view} -> {view_name}: {el_ms} ms")
|
||
else:
|
||
logger.debug(f"[TAB SWITCH] {prev_view} -> {view_name}: {el_ms} ms")
|
||
|
||
def _on_window_configure(self, event):
|
||
"""Debounce window resize to maintain 60fps smoothness."""
|
||
if event.widget != self:
|
||
return
|
||
if self._resize_timer_id:
|
||
try:
|
||
self.after_cancel(self._resize_timer_id)
|
||
except Exception:
|
||
pass
|
||
self._resize_timer_id = self.after(100, self._handle_debounced_resize)
|
||
|
||
def _handle_debounced_resize(self):
|
||
self._resize_timer_id = None
|
||
|
||
# ─────── Data Refresh (Threaded via Scheduler & HubStateStore) ───────
|
||
|
||
def _refresh_data(self):
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
self.status_left.configure(text="Обновление состояния...")
|
||
except Exception:
|
||
pass
|
||
|
||
def _load():
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
|
||
snap = HubStateStore.get().refresh(force_scan=True)
|
||
if not self._shutting_down:
|
||
self.after(0, lambda: self._on_data_loaded(snap))
|
||
except Exception as e:
|
||
if not self._shutting_down:
|
||
try:
|
||
self.after(0, lambda err=str(e): self._on_data_error(err))
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_load, daemon=True).start()
|
||
|
||
def _on_data_loaded(self, snapshot_or_readiness: Any):
|
||
if self._shutting_down:
|
||
return
|
||
|
||
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
|
||
|
||
if isinstance(snapshot_or_readiness, HubSnapshot):
|
||
snap = snapshot_or_readiness
|
||
readiness = snap.readiness
|
||
else:
|
||
snap = HubStateStore.get().get_snapshot()
|
||
readiness = snapshot_or_readiness
|
||
|
||
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
|
||
self.status_left.configure(
|
||
text=f"● {readiness.title_ru}",
|
||
text_color=Theme.STATUS_HEALTHY
|
||
if readiness.state == "healthy"
|
||
else Theme.STATUS_WARNING
|
||
if readiness.state in ("limited", "degraded")
|
||
else Theme.STATUS_ERROR,
|
||
)
|
||
|
||
self.status_right.configure(
|
||
text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей",
|
||
text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED,
|
||
)
|
||
|
||
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
||
curr_view = self._views.get(self._current_view)
|
||
if curr_view and hasattr(curr_view, "update_data"):
|
||
try:
|
||
curr_view.update_data(snap)
|
||
except Exception as ex:
|
||
logger.warning("Error updating current view %s: %s", self._current_view, ex)
|
||
self._view_generations[self._current_view] = snap.generation
|
||
self._update_auxiliary_data(curr_view)
|
||
|
||
@staticmethod
|
||
def _update_auxiliary_data(view: Any) -> None:
|
||
if hasattr(view, "update_events"):
|
||
try:
|
||
view.update_events(EventLogService.get().get_events(limit=20))
|
||
except Exception as ex:
|
||
logger.warning("Error updating event presentation: %s", ex)
|
||
|
||
def _on_data_error(self, error: str):
|
||
if self._shutting_down:
|
||
return
|
||
self.status_left.configure(text=f"Ошибка: {error}")
|
||
|
||
# ─────── Action Handler ───────
|
||
|
||
def _handle_action(self, action: str, data: Dict[str, Any]):
|
||
pid = data.get("profile_id", "")
|
||
prov = data.get("provider", "")
|
||
|
||
if action == "set_main":
|
||
self._run_in_thread(
|
||
lambda: do_set_main(prov, pid),
|
||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||
)
|
||
elif action == "set_orchestrator":
|
||
self._run_in_thread(
|
||
lambda: do_set_orchestrator(pid),
|
||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||
)
|
||
elif action == "test":
|
||
self._show_toast(f"⚡ Тестирование {data.get('display_name', pid)}...")
|
||
self._run_in_thread(
|
||
lambda: do_test_profile(prov, pid),
|
||
on_success=self._show_test_result,
|
||
)
|
||
elif action == "oauth" or action == "add_account":
|
||
self._open_add_account_wizard()
|
||
elif action == "delete_credentials":
|
||
self._run_in_thread(
|
||
lambda: do_delete_credentials(prov, pid),
|
||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||
)
|
||
elif action == "assign_role":
|
||
self._open_assign_role_modal(pid, data.get("display_name", pid))
|
||
elif action == "auto_assign_all":
|
||
self._show_toast("⚡ Автоматическое распределение ролей...")
|
||
self._run_in_thread(
|
||
lambda: AutoAssigner.auto_assign_all(),
|
||
on_success=lambda r: self._show_toast("✅ Роли успешно распределены"),
|
||
)
|
||
elif action == "refresh_data":
|
||
self._refresh_data()
|
||
elif action == "refresh_all":
|
||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||
|
||
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
|
||
elif action == "refresh_account":
|
||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||
|
||
HermesRefreshScheduler.get().trigger_refresh_account(
|
||
prov,
|
||
pid,
|
||
on_complete=lambda: self.after(0, self._refresh_data),
|
||
)
|
||
elif action == "edit_route":
|
||
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.")
|
||
elif action == "save_settings":
|
||
|
||
def _settings_saved(result: Tuple[bool, str]) -> None:
|
||
requested_theme = str(data.get("theme", self._theme_name))
|
||
if requested_theme != self._theme_name:
|
||
self._apply_theme(requested_theme)
|
||
self._show_toast(f"✅ {result[1]}")
|
||
|
||
self._run_in_thread(
|
||
lambda: do_save_settings(data),
|
||
on_success=_settings_saved,
|
||
)
|
||
elif action == "check_updates":
|
||
from antigravity_provider.updater import UpdateManager
|
||
|
||
self._run_in_thread(
|
||
lambda: UpdateManager().check_for_updates(),
|
||
on_success=lambda result: self._show_toast(
|
||
f"Доступна версия {result.manifest.version}"
|
||
if result.update_available and result.manifest
|
||
else (f"Ошибка: {result.error}" if result.error else "Установлена актуальная версия")
|
||
),
|
||
)
|
||
|
||
def _open_assign_role_modal(self, profile_id: str, display_name: str):
|
||
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)
|
||
|
||
ctk.CTkLabel(
|
||
modal.body,
|
||
text=f"Выберите роль в команде Hermes для профиля «{display_name}» ({profile_id}):",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
wraplength=440,
|
||
justify="left",
|
||
).pack(anchor="w", pady=(0, 12))
|
||
|
||
role_var = ctk.StringVar(value="orchestrator")
|
||
roles = [
|
||
("orchestrator", "👑 Главный оркестратор"),
|
||
("coder", "💻 Кодер (Code Generation)"),
|
||
("reviewer", "🔍 Ревьюер (Code Review)"),
|
||
("researcher", "🌐 Исследователь (Search / Docs)"),
|
||
("tester", "🧪 Тестировщик (Deterministic Tests)"),
|
||
("general", "⚡ Агент общего назначения (Subagent)"),
|
||
("spare", "🛡️ Резерв (Spare)"),
|
||
]
|
||
|
||
for val, lbl in roles:
|
||
ctk.CTkRadioButton(
|
||
modal.body,
|
||
text=lbl,
|
||
variable=role_var,
|
||
value=val,
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
fg_color=Theme.ACCENT,
|
||
hover_color=Theme.ACCENT_HOVER,
|
||
).pack(anchor="w", padx=8, pady=3)
|
||
|
||
def _save():
|
||
chosen = role_var.get()
|
||
ok, msg = AutoAssigner.assign_profile_to_role(profile_id, chosen, is_primary=(chosen != "spare"))
|
||
modal.destroy()
|
||
self._show_toast(f"✅ {msg}" if ok else f"❌ {msg}")
|
||
self._refresh_data()
|
||
|
||
HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left")
|
||
HubButton(modal.footer, text="Применить роль", variant="primary", width=160, command=_save).pack(side="right")
|
||
|
||
def _open_add_account_wizard(self):
|
||
wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete)
|
||
|
||
def _on_wizard_complete(self, result: Dict[str, Any]):
|
||
self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён")
|
||
self._refresh_data()
|
||
|
||
def _show_test_result(self, result: Dict[str, Any]):
|
||
if result.get("success"):
|
||
msg = f"✓ Тест успешен | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s"
|
||
else:
|
||
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
|
||
self._show_toast(msg)
|
||
|
||
def _apply_theme(self, scheme: str) -> None:
|
||
"""Apply all palette tokens by rebuilding presentation widgets in place."""
|
||
current_view = self._current_view
|
||
self._theme_name = Theme.apply_scheme(scheme)
|
||
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
|
||
for child in list(self.winfo_children()):
|
||
child.destroy()
|
||
self._views.clear()
|
||
self._view_generations.clear()
|
||
self.configure(fg_color=Theme.BG_WINDOW)
|
||
self._build_layout()
|
||
self._show_view(current_view)
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
|
||
self._on_data_loaded(HubStateStore.get().get_snapshot())
|
||
|
||
def _run_in_thread(self, func, on_success=None, on_error=None):
|
||
def _worker():
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
result = func()
|
||
if not self._shutting_down:
|
||
if on_success:
|
||
self.after(0, lambda: on_success(result))
|
||
self.after(300, self._refresh_data)
|
||
except Exception as e:
|
||
if not self._shutting_down:
|
||
try:
|
||
if on_error:
|
||
self.after(0, lambda err=str(e): on_error(err))
|
||
else:
|
||
self.after(0, lambda err=str(e): self._show_toast(f"❌ Ошибка: {err}"))
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
def _show_toast(self, message: str):
|
||
if self._shutting_down:
|
||
return
|
||
self.status_left.configure(text=message)
|
||
self.after(6000, self._restore_status)
|
||
|
||
def _restore_status(self):
|
||
if self._shutting_down:
|
||
return
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
|
||
readiness = HubStateStore.get().get_snapshot().readiness
|
||
self.status_left.configure(
|
||
text=f"● {readiness.title_ru}",
|
||
text_color=Theme.STATUS_HEALTHY
|
||
if readiness.state == "healthy"
|
||
else Theme.STATUS_WARNING
|
||
if readiness.state in ("limited", "degraded")
|
||
else Theme.STATUS_ERROR,
|
||
)
|
||
|
||
def _on_close(self):
|
||
"""Graceful shutdown coordinator without leaving orphan processes."""
|
||
self._shutting_down = True
|
||
try:
|
||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||
|
||
HermesRefreshScheduler.get().stop()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||
|
||
AccountQuotaService.get().stop_background_scheduler()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self._resize_timer_id:
|
||
self.after_cancel(self._resize_timer_id)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
global _APP_MUTEX_HANDLE
|
||
if _APP_MUTEX_HANDLE and sys.platform == "win32":
|
||
ctypes.windll.kernel32.CloseHandle(_APP_MUTEX_HANDLE)
|
||
_APP_MUTEX_HANDLE = None
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.destroy()
|
||
except Exception:
|
||
pass
|
||
os._exit(0)
|
||
|
||
|
||
_APP_MUTEX_HANDLE = None
|
||
|
||
|
||
def check_single_instance() -> bool:
|
||
"""Ensure only one Hermes Hub instance runs. If already running, activate existing window and return False."""
|
||
global _APP_MUTEX_HANDLE
|
||
if sys.platform != "win32":
|
||
return True
|
||
try:
|
||
user32 = ctypes.windll.user32
|
||
kernel32 = ctypes.windll.kernel32
|
||
|
||
mutex_name = "Global\\HermesHubSingleInstanceMutex"
|
||
mutex = kernel32.CreateMutexW(None, True, mutex_name)
|
||
last_err = kernel32.GetLastError()
|
||
|
||
# ERROR_ALREADY_EXISTS = 183
|
||
if last_err == 183:
|
||
hwnd = user32.FindWindowW(None, "Hermes Hub")
|
||
if hwnd:
|
||
user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||
user32.SetForegroundWindow(hwnd)
|
||
if mutex:
|
||
kernel32.CloseHandle(mutex)
|
||
return False
|
||
|
||
_APP_MUTEX_HANDLE = mutex
|
||
return True
|
||
except Exception:
|
||
return True
|
||
|
||
|
||
def launch_hub():
|
||
from antigravity_provider import paths
|
||
from antigravity_provider.version import __version__
|
||
|
||
# Startup logging
|
||
log_file = paths.get_startup_log_file()
|
||
try:
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting Hermes Hub v{__version__} (PID {os.getpid()})\n")
|
||
except Exception:
|
||
pass
|
||
|
||
if not check_single_instance():
|
||
try:
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(
|
||
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Another instance is already running. Focused existing window and exiting.\n"
|
||
)
|
||
except Exception:
|
||
pass
|
||
sys.exit(0)
|
||
|
||
try:
|
||
app = HermesHubApp()
|
||
app.mainloop()
|
||
except Exception as exc:
|
||
try:
|
||
import traceback
|
||
|
||
tb = traceback.format_exc()
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] FATAL CRASH:\n{tb}\n")
|
||
if sys.platform == "win32":
|
||
ctypes.windll.user32.MessageBoxW(
|
||
0,
|
||
f"Произошла критическая ошибка при запуске Hermes Hub:\n\n{exc}\n\nПодробности записаны в: {log_file}",
|
||
"Hermes Hub — Ошибка запуска",
|
||
0x10, # MB_ICONERROR
|
||
)
|
||
except Exception:
|
||
pass
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
launch_hub()
|