"""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 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.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.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 logger = logging.getLogger("hermes.hub.gui") # ═══════════════════════════════════════════════════════════════ # 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, "Учетные данные отсутствовали" # ═══════════════════════════════════════════════════════════════ # Main Application Window # ═══════════════════════════════════════════════════════════════ class HermesHubApp(ctk.CTk): def __init__(self): super().__init__() self.title("Hermes Hub") self.geometry("1380x880") self.minsize(1100, 700) ctk.set_appearance_mode("dark") 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 = "team" 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("", self._on_window_configure) self._build_layout() self._show_view("team") 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=16, pady=(20, 14)) logo_img = AssetManager.get().get_logo_image(size=(72, 72)) 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_title_hero(), text_color=Theme.TEXT_ACCENT).pack(anchor="center") ctk.CTkLabel(brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="center") # Divider ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12)) # Nav Items with clean Fluent glyphs self._nav_items = [ ("team", "Команда", "👥"), ("accounts", "Аккаунты", "🔍"), ("routing", "Маршрутизация", "🔀"), ("providers", "Провайдеры", "🌐"), ("health", "Состояние системы", "🛡️"), ("logs", "Журнал событий", "📜"), ("settings", "Настройки", "⚙️"), ("about", "О программе", "ℹ️"), ] self._nav_buttons: Dict[str, ctk.CTkButton] = {} for key, label, icon in self._nav_items: btn = ctk.CTkButton( self.sidebar, text=f" {icon} {label}", font=Theme.font_body(), height=Theme.HEIGHT_NAV_ITEM, fg_color="transparent", hover_color=Theme.SURFACE_HOVER, text_color=Theme.TEXT_PRIMARY, anchor="w", corner_radius=Theme.RADIUS_SM, command=lambda k=key: self._show_view(k), ) btn.pack(fill="x", padx=12, pady=2) self._nav_buttons[key] = btn # Refresh button at bottom of sidebar refresh_btn = HubButton( self.sidebar, text="🔄 Обновить", variant="secondary", height=38, command=self._refresh_data, ) refresh_btn.pack(side="bottom", fill="x", padx=14, pady=16) # ── Status Bar (Bottom) ── self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_STATUSBAR, fg_color=Theme.BG_STATUSBAR, corner_radius=0) self.statusbar.pack(side="bottom", fill="x") self.status_left = ctk.CTkLabel( self.statusbar, text="Аккаунты: ... | Роли: ... | Обновлено: ...", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, ) self.status_left.pack(side="left", padx=16) self.status_right = ctk.CTkLabel( self.statusbar, text="● Hermes работает", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY, ) self.status_right.pack(side="right", padx=16) # ── 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 _create_view(self, view_name: str) -> ctk.CTkFrame: """Create view widget instance.""" if 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) 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) 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.SURFACE_SELECTED, text_color=Theme.TEXT_ACCENT, border_width=1, border_color=Theme.BORDER_ACCENT, ) else: btn.configure( fg_color="transparent", text_color=Theme.TEXT_PRIMARY, 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 # 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 self.status_left.configure( text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}" ) r_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"● {readiness.title_ru}", text_color=r_color) # 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 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() 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 _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.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}" ) 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()