"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3).""" from __future__ import annotations from typing import Any, Callable, Dict, List, Optional import customtkinter as ctk from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.assets import AssetManager from antigravity_provider.router.ui.components import ( HubButton, HubCard, HubMetricCard, HubSectionHeader, HubStatusBadge, ) from antigravity_provider.router.unified_health import ( AgentViewModel, STATUS_HEALTHY, STATUS_QUOTA_LOW, STATUS_QUOTA_EXHAUSTED, STATUS_AUTH_REQUIRED, STATUS_NOT_CONFIGURED, STATUS_AUTH_EXPIRED, ) from antigravity_provider.router.state_store import HubSnapshot class AgentCardWidget(HubCard): """Reusable agent card that updates in-place without rebuilding widgets.""" def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs): super().__init__(master=master, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs) self.on_action = on_action self.agent_data: Optional[AgentViewModel] = None # ── Line 1: Role Title + Badges + Status Dot ── self.line1 = ctk.CTkFrame(self, fg_color="transparent") self.line1.pack(fill="x", padx=14, pady=(12, 2)) self.role_lbl = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY) self.role_lbl.pack(side="left") self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM) self.orch_pill_lbl = ctk.CTkLabel( self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT ) self.orch_pill_lbl.pack(padx=5, pady=1) self.status_dot = ctk.CTkLabel( self.line1, text="●", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY ) self.status_dot.pack(side="right") # ── Line 2: Internal ID + Model ── self.line2 = ctk.CTkFrame(self, fg_color="transparent") self.line2.pack(fill="x", padx=14, pady=(0, 4)) self.id_model_lbl = ctk.CTkLabel(self.line2, text="—", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) self.id_model_lbl.pack(anchor="w") # ── Line 3: Provider with Real Logo ── self.line3 = ctk.CTkFrame(self, fg_color="transparent") self.line3.pack(fill="x", padx=14, pady=(2, 2)) self.prov_icon = ctk.CTkLabel(self.line3, text="") self.prov_icon.pack(side="left", padx=(0, 6)) self.prov_lbl = ctk.CTkLabel(self.line3, text="—", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) self.prov_lbl.pack(side="left") # ── Line 4: Identity / Account ── self.line4 = ctk.CTkFrame(self, fg_color="transparent") self.line4.pack(fill="x", padx=14, pady=(2, 4)) self.identity_lbl = ctk.CTkLabel( self.line4, text="—", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY ) self.identity_lbl.pack(anchor="w") self.quota_lbl = ctk.CTkLabel( self.line4, text="Квота: Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED ) self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0)) # ── Line 5: Role Tag Pills ── self.line5 = ctk.CTkFrame(self, fg_color="transparent") self.line5.pack(fill="x", padx=14, pady=(4, 6)) self.pill1 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) self.pill1.pack(side="left", padx=(0, 6)) self.pill1_lbl = ctk.CTkLabel(self.pill1, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY) self.pill1_lbl.pack(padx=6, pady=2) self.pill2 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) self.pill2.pack(side="left", padx=(0, 6)) self.pill2_lbl = ctk.CTkLabel(self.pill2, text="Primary", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) self.pill2_lbl.pack(padx=6, pady=2) # ── Line 6: Status & Menu Action ── self.line6 = ctk.CTkFrame(self, fg_color="transparent") self.line6.pack(fill="x", padx=14, pady=(4, 12)) self.status_str_lbl = ctk.CTkLabel( self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY ) self.status_str_lbl.pack(side="left") self.menu_btn = ctk.CTkButton( self.line6, text="⋮", width=28, height=24, fg_color="transparent", hover_color=Theme.SURFACE_HOVER, text_color=Theme.TEXT_SECONDARY, font=("Segoe UI", 12, "bold"), command=self._open_menu, ) self.menu_btn.pack(side="right") def update_agent(self, a: AgentViewModel): self.agent_data = a is_orch = a.is_main_orchestrator self.configure(border_color=Theme.BORDER_ACCENT if is_orch else Theme.BORDER) self.role_lbl.configure(text=a.role_name_ru) if is_orch: self.orch_pill.pack(side="left", padx=(8, 0)) else: self.orch_pill.pack_forget() # Dot color dot_color = ( Theme.STATUS_HEALTHY if a.status == STATUS_HEALTHY else ( Theme.STATUS_WARNING if "quota" in a.status or "auth" in a.status or "not_configured" in a.status else Theme.STATUS_ERROR ) ) self.status_dot.configure(text_color=dot_color) # ID + Model self.id_model_lbl.configure(text=f"{a.assigned_profile_id} • {a.model}") # Provider Icon + Label p_img = AssetManager.get().get_provider_image(a.provider, size=(18, 18)) if p_img: self.prov_icon.configure(image=p_img) self.prov_icon.pack(side="left", padx=(0, 6)) else: self.prov_icon.pack_forget() prov = a.provider.lower() if "antigravity" in prov: self.prov_lbl.configure(text="Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY) elif "codex" in prov: self.prov_lbl.configure(text="OpenAI Codex", text_color=Theme.PROVIDER_CODEX) elif "opencode" in prov: self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE) elif "claude" in prov or "anthropic" in prov: self.prov_lbl.configure(text="Claude", text_color="#d97706") elif "grok" in prov or "xai" in prov: self.prov_lbl.configure(text="Grok", text_color="#3b82f6") else: self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED) self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н/Д") quota_color = ( Theme.STATUS_HEALTHY if a.active_quota_status == "healthy" else ( Theme.STATUS_WARNING if a.active_quota_status == "warning" else Theme.STATUS_ERROR if a.active_quota_status == "exhausted" else Theme.TEXT_MUTED ) ) quota_label = a.active_quota_label or "Н/Д" session = f" • сессия {a.session_id}" if a.session_id else "" self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color) # Pills self.pill1_lbl.configure(text=a.role_id) self.pill2_lbl.configure(text=a.routing_position) # Status text cd_str = f" ({a.cooldown_remaining_sec}s)" if a.cooldown_remaining_sec > 0 else "" self.status_str_lbl.configure(text=f"● {a.status_label_ru.upper()}{cd_str}", text_color=dot_color) def _open_menu(self): if not self.agent_data: return a = self.agent_data pid = a.assigned_profile_id or "" popup = ctk.CTkToplevel(self.winfo_toplevel()) popup.title(f"Действия: {a.role_name_ru}") popup.geometry("320x260") popup.configure(fg_color=Theme.DARK) popup.resizable(False, False) popup.transient(self.winfo_toplevel()) popup.grab_set() popup.update_idletasks() px = self.winfo_toplevel().winfo_x() + 300 py = self.winfo_toplevel().winfo_y() + 200 popup.geometry(f"+{px}+{py}") c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT) c.pack(fill="both", expand=True, padx=10, pady=10) ctk.CTkLabel(c, text=a.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2)) ctk.CTkLabel( c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED ).pack(pady=(0, 10)) def _do(action_name: str): popup.destroy() if self.on_action: self.on_action( action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name} ) HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack( fill="x", padx=12, pady=2 ) HubButton( c, text="👑 Назначить главным оркестратором", variant="secondary", height=30, command=lambda: _do("set_orchestrator"), ).pack(fill="x", padx=12, pady=2) HubButton( c, text="★ Сделать основным аккаунтом Hermes", variant="secondary", height=30, command=lambda: _do("set_main"), ).pack(fill="x", padx=12, pady=2) HubButton( c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all") ).pack(fill="x", padx=12, pady=(2, 0)) class TeamView(ctk.CTkFrame): def __init__( self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs ): super().__init__(master=master, fg_color="transparent", **kwargs) self.app_state = app_state or {} self.on_action = on_action self._card_widgets: Dict[str, AgentCardWidget] = {} self._build_static_layout() self.update_data() def _build_static_layout(self): # ── 1. Top Section Header ── header_frame = ctk.CTkFrame(self, fg_color="transparent") header_frame.pack(fill="x", padx=20, pady=(16, 12)) left_titles = ctk.CTkFrame(header_frame, fg_color="transparent") left_titles.pack(side="left") ctk.CTkLabel( left_titles, text="Команда агентов", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY, ).pack(anchor="w") ctk.CTkLabel( left_titles, text="Управляйте командой Hermes и их ролями", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED, ).pack(anchor="w", pady=(2, 0)) right_actions = ctk.CTkFrame(header_frame, fg_color="transparent") right_actions.pack(side="right") HubButton( right_actions, text="+ Добавить агент", variant="primary", height=Theme.HEIGHT_BTN_MD, command=lambda: self._trigger_action("add_account", {}), ).pack(side="left", padx=(0, 8)) ctk.CTkButton( right_actions, text="⋮", width=38, height=Theme.HEIGHT_BTN_MD, fg_color=Theme.SURFACE, hover_color=Theme.SURFACE_HOVER, text_color=Theme.TEXT_PRIMARY, font=("Segoe UI", 14, "bold"), corner_radius=Theme.RADIUS_SM, command=lambda: self._trigger_action("auto_assign_all", {}), ).pack(side="left") # ── Scrollable Body ── self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") self.scroll.pack(fill="both", expand=True, padx=15, pady=(0, 8)) # ── 2. Top 4 Metric Cards (Real Readiness) ── metrics_grid = ctk.CTkFrame(self.scroll, fg_color="transparent") metrics_grid.pack(fill="x", pady=(0, 16)) for i in range(4): metrics_grid.grid_columnconfigure(i, weight=1) self.m1 = HubMetricCard( metrics_grid, title="АГЕНТЫ", value="0/6", subtext="готовы к работе", icon="👥", accent=True ) self.m1.grid(row=0, column=0, padx=6, sticky="nsew") self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼") self.m2.grid(row=0, column=1, padx=6, sticky="nsew") self.m3 = HubMetricCard(metrics_grid, title="ПРОВАЙДЕРЫ", value="3/3", subtext="доступно", icon="⚛") self.m3.grid(row=0, column=2, padx=6, sticky="nsew") self.m4 = HubMetricCard( metrics_grid, title="СОСТОЯНИЕ", value="Healthy", subtext="Все системы работают", icon="🛡️" ) self.m4.grid(row=0, column=3, padx=6, sticky="nsew") # ── 3. Hierarchy: orchestrator → role agents ── ctk.CTkLabel( self.scroll, text="ОРКЕСТРАТОР", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, ).pack(anchor="w", padx=Theme.SPACE_XS) self.orchestrator_grid = ctk.CTkFrame(self.scroll, fg_color="transparent") self.orchestrator_grid.pack(fill="x", pady=(Theme.SPACE_XS, Theme.SECTION_GAP)) self.orchestrator_grid.grid_columnconfigure(0, weight=1) ctk.CTkLabel( self.scroll, text="РОЛИ И АГЕНТЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, ).pack(anchor="w", padx=Theme.SPACE_XS) self.cards_grid = ctk.CTkFrame(self.scroll, fg_color="transparent") self.cards_grid.pack(fill="both", expand=True) for col_idx in range(3): self.cards_grid.grid_columnconfigure(col_idx, weight=1) def update_data(self, snapshot: Optional[Any] = None): if not isinstance(snapshot, HubSnapshot): return readiness = snapshot.readiness agents = snapshot.agents # Update metric cards self.m1.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}") self.m1.sub_label.configure(text="ролей готовы") self.m2.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}") self.m2.sub_label.configure(text="подключено") self.m3.val_label.configure(text=f"{readiness.providers_ready_count}/{readiness.total_providers}") self.m3.sub_label.configure(text="доступно") self.m4.val_label.configure(text=readiness.title_ru) self.m4.sub_label.configure(text=readiness.summary_ru) live_roles = {agent.role_id for agent in agents} for role_id in list(self._card_widgets): if role_id not in live_roles: self._card_widgets.pop(role_id).destroy() orchestrators = [agent for agent in agents if agent.is_main_orchestrator] role_agents = [agent for agent in agents if not agent.is_main_orchestrator] for index, agent in enumerate(orchestrators): card = self._card_widgets.get(agent.role_id) if card is None: card = AgentCardWidget(self.orchestrator_grid, on_action=self.on_action) self._card_widgets[agent.role_id] = card card.update_agent(agent) card.grid(row=index, column=0, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew") for index, agent in enumerate(role_agents): card = self._card_widgets.get(agent.role_id) if card is None: card = AgentCardWidget(self.cards_grid, on_action=self.on_action) self._card_widgets[agent.role_id] = card card.update_agent(agent) card.grid(row=index // 3, column=index % 3, padx=6, pady=6, sticky="nsew") def _trigger_action(self, action: str, profile: Dict[str, Any]): if self.on_action: self.on_action(action, profile)