399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""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)
|