feat: add cockpit-style model and card controls

This commit is contained in:
Hermes Team 2026-08-23 10:54:08 +07:00
parent 6494c5198a
commit 7468c7cc8b
9 changed files with 575 additions and 175 deletions

View file

@ -51,7 +51,7 @@ 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.components import AccountCardWidget, HubButton, HubModal
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
from antigravity_provider.router.unified_health import (
@ -60,6 +60,7 @@ from antigravity_provider.router.unified_health import (
SystemReadiness,
STATUS_HEALTHY,
)
from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
@ -71,29 +72,10 @@ 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
from antigravity_provider.router.ui.model_catalog import get_cached_models, refresh_models_async
logger = logging.getLogger("hermes.hub.gui")
AGENT_MODEL_OPTIONS = {
"antigravity": [
# User-facing logical IDs. The provider layer maps Gemini 3.1 Pro
# to the current low/high wire variants according to reasoning effort.
"gemini-3.1-pro",
"gemini-2.5-pro",
"gemini-3.7-flash",
"gemini-3.6-flash-high",
"gemini-3.5-flash",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
],
"openai-codex": ["gpt-4o", "o3-mini", "codex"],
"opencode-go": ["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
"claude": ["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
"grok": ["grok-3", "grok-3-mini", "grok-2"],
}
def _load_saved_theme() -> str:
settings_file = paths.get_hermes_home() / "hub_settings.json"
try:
@ -300,7 +282,6 @@ class HermesHubApp(ctk.CTk):
("accounts", "Аккаунты", "accounts"),
("routing", "Маршрутизация", "routing"),
("providers", "Модели и провайдеры", "providers"),
("quotas", "Сводка лимитов", "quotas"),
("analytics", "Аналитика", "analytics"),
("health", "Состояние", "health"),
("logs", "Журнал событий", "logs"),
@ -465,8 +446,6 @@ class HermesHubApp(ctk.CTk):
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":
@ -655,6 +634,8 @@ class HermesHubApp(ctk.CTk):
)
elif action == "assign_role":
self._open_assign_role_modal(pid, data.get("display_name", pid))
elif action == "account_details":
self._open_account_details_modal(pid)
elif action == "agent_settings":
self._open_agent_settings_modal(
data.get("role_id", ""),
@ -799,6 +780,146 @@ class HermesHubApp(ctk.CTk):
modal.save_button = save_button
return modal
def _open_account_details_modal(self, profile_id: str):
"""Open account details from a click anywhere on its compact row."""
snapshot = HubStateStore.get().get_snapshot()
profile = snapshot.all_profiles.get(profile_id)
if profile is None:
self._show_toast(f"❌ Профиль '{profile_id}' не найден")
return None
modal = HubModal(self, title=AccountCardWidget.resolve_identity(profile), width=680, height=520)
summary = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
summary.pack(fill="x", pady=(0, Theme.SPACE_MD))
roles = ", ".join(profile.assigned_roles or []) or "роль не назначена"
ctk.CTkLabel(
summary,
text=f"{profile.provider_display_name}{profile.display_name}",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=12, pady=(10, 2))
ctk.CTkLabel(
summary,
text=f"Роли: {roles} • Авторизация: {profile.auth_label_ru}{profile.health_label_ru}",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=12, pady=(0, 10))
config = load_router_config()
profile_config = config.profiles.get(profile_id)
catalog = get_cached_models(profile.provider)
configured_model = (
profile_config.preferred_models[0] if profile_config and profile_config.preferred_models else ""
)
model_values = list(catalog.models)
if configured_model and configured_model not in model_values:
model_values.insert(0, configured_model)
model_var = ctk.StringVar(
value=configured_model or (model_values[0] if model_values else "Список моделей ещё не получен")
)
model_row = ctk.CTkFrame(modal.body, fg_color="transparent")
model_row.pack(fill="x", pady=(0, Theme.SPACE_SM))
model_menu = ctk.CTkOptionMenu(
model_row,
values=model_values or ["Список моделей ещё не получен"],
variable=model_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
button_hover_color=Theme.ACCENT_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
model_menu.pack(side="left", fill="x", expand=True)
model_note = catalog.unavailable_reason if not catalog.models else (
f"⚠ «{configured_model}» отсутствует в обнаруженном списке"
if configured_model and configured_model not in catalog.models
else f"{len(catalog.models)} моделей из кэша"
)
ctk.CTkLabel(
modal.body,
text=model_note,
font=Theme.font_micro(),
text_color=Theme.STATUS_WARNING if not catalog.models or configured_model not in catalog.models else Theme.TEXT_MUTED,
).pack(anchor="w", pady=(0, Theme.SPACE_SM))
def _save_profile_model() -> None:
selected = model_var.get()
if selected == "Список моделей ещё не получен" or not profile_config:
self._show_toast("❌ Сначала получите список моделей")
return
updated = load_router_config()
target = updated.profiles[profile_id]
target.preferred_models = [selected] + [item for item in target.preferred_models if item != selected]
updated.profiles[profile_id] = target
if save_router_config(updated):
self._show_toast(f"✅ Модель профиля сохранена: {selected}")
self._refresh_data()
else:
self._show_toast("Не удалось сохранить модель профиля")
HubButton(
model_row,
text="Сохранить модель",
variant="secondary",
width=140,
command=_save_profile_model,
).pack(side="right", padx=(Theme.SPACE_SM, 0))
HubButton(
model_row,
text="Обновить список",
variant="ghost",
width=120,
command=lambda: refresh_models_async(
profile.provider,
lambda _result: self.after(
0,
lambda: (modal.destroy(), self._open_account_details_modal(profile_id)),
),
),
).pack(side="right", padx=(Theme.SPACE_SM, 0))
ctk.CTkLabel(
modal.body, text="Квоты и периоды", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(anchor="w")
quota_box = ctk.CTkFrame(modal.body, fg_color="transparent")
quota_box.pack(fill="both", expand=True, pady=(4, 8))
quota = snapshot.quotas.get(profile_id)
buckets = list(getattr(quota, "buckets", None) or [])
if not buckets:
reason = getattr(quota, "unavailable_reason", None) or "Провайдер не отдал данные о лимитах"
ctk.CTkLabel(
quota_box, text=f"Н/Д — {reason}", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
).pack(anchor="w", pady=8)
for bucket in buckets:
row = ctk.CTkFrame(quota_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
row.pack(fill="x", pady=2)
ctk.CTkLabel(
row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
).pack(side="left", padx=10, pady=7)
detail = bucket.formatted_remaining()
if detail == "Н":
detail = getattr(bucket, "unavailable_reason", None) or getattr(quota, "unavailable_reason", None) or detail
reset = bucket.formatted_reset() or "время сброса не предоставлено"
ctk.CTkLabel(
row,
text=f"{detail}{reset}",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(side="right", padx=10, pady=7)
HubButton(
modal.footer,
text="Проверить",
variant="secondary",
command=lambda: (modal.destroy(), self._handle_action("test", profile.__dict__)),
).pack(side="left", padx=(0, 5))
HubButton(
modal.footer,
text="Назначить роль",
variant="secondary",
command=lambda: (modal.destroy(), self._open_assign_role_modal(profile_id, profile.display_name)),
).pack(side="left")
HubButton(modal.footer, text="Закрыть", variant="primary", command=modal.destroy).pack(side="right")
return modal
def _open_route_editor_modal(self, role_id: str):
"""Direct, visible editor for one ordered failover chain."""
config = load_router_config()
@ -905,6 +1026,8 @@ class HermesHubApp(ctk.CTk):
config = load_router_config()
role = config.roles.get(role_id)
live_snapshot = HubStateStore.get().get_snapshot()
pipeline = live_snapshot.get_role_pipeline(role_id)
role_labels = {
"orchestrator": "Главный оркестратор",
"coder-primary": "Кодер 1",
@ -913,7 +1036,7 @@ class HermesHubApp(ctk.CTk):
"research": "Исследователь",
"fast": "Быстрый агент",
}
modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=560, height=540)
modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=620, height=720)
ctk.CTkLabel(
modal.body,
text="Аккаунт, модель и реальные лимиты агента",
@ -921,6 +1044,32 @@ class HermesHubApp(ctk.CTk):
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", pady=(0, 12))
chain_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
chain_card.pack(fill="x", pady=(0, 10))
chain = list(role.preferred_chain) if role else []
chain_text = "".join(
f"{'' if pipeline and node_id == pipeline.active_profile_id else ''}{node_id}" for node_id in chain
) or "Профили не назначены"
active_node = next(
(node for node in list(getattr(pipeline, "nodes", []) or []) if node.profile_id == pipeline.active_profile_id),
None,
)
failover_reason = getattr(active_node, "failover_reason", None) or "переключений ещё не было"
ctk.CTkLabel(
chain_card,
text=f"Цепочка: {chain_text}",
font=Theme.font_caption(),
text_color=Theme.TEXT_PRIMARY,
anchor="w",
).pack(fill="x", padx=10, pady=(8, 2))
ctk.CTkLabel(
chain_card,
text=f"Последнее переключение: {failover_reason}",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
anchor="w",
).pack(fill="x", padx=10, pady=(0, 8))
choices: dict[str, str] = {}
for pid, pcfg in config.profiles.items():
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
@ -969,6 +1118,65 @@ class HermesHubApp(ctk.CTk):
text_color=Theme.TEXT_PRIMARY,
)
model_menu.pack(fill="x", pady=(3, 10))
model_status = ctk.CTkLabel(
modal.body,
text="",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
anchor="w",
justify="left",
)
model_status.pack(fill="x", pady=(0, 5))
def _apply_cached_models() -> None:
current_pid = choices[account_var.get()]
current_cfg = load_router_config().profiles[current_pid]
catalog = get_cached_models(current_cfg.provider)
configured = (
role.default_model
if role and role.default_model
else current_cfg.preferred_models[0]
if current_cfg.preferred_models
else ""
)
values = list(catalog.models)
if configured and configured not in values:
values.insert(0, configured)
model_menu.configure(values=values or ["Список моделей ещё не получен"])
model_var.set(configured or (values[0] if values else "Список моделей ещё не получен"))
if not catalog.models:
model_status.configure(text=catalog.unavailable_reason, text_color=Theme.STATUS_WARNING)
elif configured and configured not in catalog.models:
model_status.configure(
text=f"⚠ Настроенная модель «{configured}» отсутствует в обнаруженном списке. Она не изменена.",
text_color=Theme.STATUS_WARNING,
)
else:
freshness = f" • получено {catalog.fetched_at}" if catalog.fetched_at else ""
stale = " • кэш устарел" if catalog.is_stale else ""
model_status.configure(
text=f"{len(catalog.models)} моделей из кэша{freshness}{stale}",
text_color=Theme.TEXT_MUTED if not catalog.is_stale else Theme.STATUS_WARNING,
)
def _refresh_models() -> None:
current_pid = choices[account_var.get()]
provider = load_router_config().profiles[current_pid].provider
model_status.configure(text="Обновление моделей запущено в фоне…", text_color=Theme.TEXT_SECONDARY)
started = refresh_models_async(provider, lambda _result: self.after(0, _apply_cached_models))
if not started:
model_status.configure(
text="Фоновое обновление пока недоступно; показан последний кэш.",
text_color=Theme.STATUS_WARNING,
)
HubButton(
modal.body,
text="Обновить список моделей",
variant="secondary",
height=30,
command=_refresh_models,
).pack(anchor="w", pady=(0, 8))
quota_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
quota_card.pack(fill="both", expand=True, pady=(2, 10))
@ -982,14 +1190,7 @@ class HermesHubApp(ctk.CTk):
def _refresh_account_panel(_choice: Optional[str] = None) -> None:
current_pid = choices[account_var.get()]
current_cfg = load_router_config().profiles[current_pid]
available = list(
dict.fromkeys(current_cfg.preferred_models + AGENT_MODEL_OPTIONS.get(current_cfg.provider, []))
)
if not available:
available = ["default"]
model_menu.configure(values=available)
current_model = role.default_model if role and role.default_model in available else available[0]
model_var.set(current_model)
_apply_cached_models()
for child in quota_rows.winfo_children():
child.destroy()
snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid)
@ -1021,6 +1222,9 @@ class HermesHubApp(ctk.CTk):
def _save_agent() -> None:
current_pid = choices[account_var.get()]
current_model = model_var.get()
if current_model == "Список моделей ещё не получен":
result.configure(text="✕ Сначала получите список моделей", text_color=Theme.STATUS_ERROR)
return
updated = load_router_config()
profile = updated.profiles[current_pid]
profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model]

View file

@ -808,6 +808,7 @@ class RouteTargetWidget(HubCard):
self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.quota = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.quota.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.quota_tooltip = Tooltip(self.quota, "")
self.failover = ctk.CTkLabel(
self, text="", font=Theme.font_micro(), text_color=Theme.COLOR_CAUTION, wraplength=260, justify="left"
)
@ -830,7 +831,17 @@ class RouteTargetWidget(HubCard):
"warning": "Квота: заканчивается",
"exhausted": "Квота: исчерпана",
}
self.quota.configure(text=quota_labels.get(quota_status, "Квота: Н"))
if quota_status in quota_labels:
quota_text = quota_labels[quota_status]
quota_reason = ""
elif status in {"not_configured", "auth_required", "auth_expired"}:
quota_text = "Квота: Н/Д — аккаунт не подключён"
quota_reason = "Аккаунт не авторизован, поэтому лимиты запросить нельзя"
else:
quota_text = "Квота: Н/Д — провайдер не отдаёт лимиты"
quota_reason = "В текущем ответе провайдера отсутствуют измеримые данные квоты"
self.quota.configure(text=quota_text)
self.quota_tooltip.text = quota_reason
if failover_reason:
self.failover.configure(text=f"Переключено: {failover_reason}")
self.failover.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM))
@ -864,7 +875,9 @@ class AccountCardWidget(HubCard):
on_action: Optional[Callable[[str, Any], None]] = None,
**kwargs,
):
super().__init__(master, **kwargs)
super().__init__(master, height=148, **kwargs)
self.pack_propagate(False)
self.grid_propagate(False)
self.profile_id = profile_id
self.profile_model: Any = None
self.on_action = on_action
@ -877,20 +890,31 @@ class AccountCardWidget(HubCard):
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.provider = ctk.CTkLabel(top, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.provider.pack(side="left")
self.profile_mark = ctk.CTkLabel(top, text="", font=Theme.font_caption(), text_color=Theme.TEXT_ACCENT)
self.profile_mark = ctk.CTkButton(
top,
text="",
width=28,
height=20,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_SECONDARY,
command=lambda: self._trigger("account_details"),
)
self.profile_mark.pack(side="right")
self.identity = EllipsizedLabel(self, text=identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.meta.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0))
self.plan_badge = PlanBadge(self, text="")
self.status = StatusBadge(self, status)
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.badges = ctk.CTkFrame(self, fg_color="transparent")
self.badges.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM))
self.plan_badge = PlanBadge(self.badges, text="")
self.status = StatusBadge(self.badges, status)
self.status.pack(side="left")
self.compact_quota = ctk.CTkFrame(self, fg_color="transparent")
self.compact_quota_cells: list[dict[str, Any]] = []
for index in range(4):
for index in range(1):
cell = ctk.CTkFrame(self.compact_quota, fg_color="transparent")
cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 10), pady=(2, 6))
title = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY, anchor="w")
@ -978,6 +1002,35 @@ class AccountCardWidget(HubCard):
command=lambda: self._trigger("delete_credentials"),
).pack(side="right")
self.set_compact(True)
self._bind_details_click(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._open_details, add="+")
self.bind("<space>", self._open_details, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_details_click(self, widget: Any) -> None:
if isinstance(widget, ctk.CTkButton):
return
widget.configure(cursor="hand2")
widget.bind("<Button-1>", self._open_details, add="+")
for child in widget.winfo_children():
self._bind_details_click(child)
def _open_details(self, _event: Any = None) -> str:
self._trigger("account_details")
return "break"
def _hover_on(self, _event: Any = None) -> None:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
profile = self.profile_model
border = Theme.BORDER_ACCENT if profile and getattr(profile, "is_main_account", False) else Theme.BORDER
self.configure(fg_color=Theme.SURFACE, border_color=border)
@staticmethod
def resolve_identity(profile: Any) -> str:
@ -1015,7 +1068,7 @@ class AccountCardWidget(HubCard):
self.compact = True
self.details.pack_forget()
self.compact_quota.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_XS))
self.compact_actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
self.compact_actions.pack_forget()
def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None:
self.profile_model = profile
@ -1027,12 +1080,7 @@ class AccountCardWidget(HubCard):
if self.plan_badge.set_plan(
getattr(profile, "plan_code", "UNKNOWN"), getattr(profile, "plan_source", "unknown")
):
self.plan_badge.pack(
anchor="w",
padx=Theme.CARD_PAD_X,
pady=(Theme.SPACE_SM, 0),
before=self.status,
)
self.plan_badge.pack(side="left", padx=(0, Theme.SPACE_XS), before=self.status)
else:
self.plan_badge.pack_forget()
self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER)
@ -1057,9 +1105,16 @@ class AccountCardWidget(HubCard):
card_health = profile.health_state
card_label = getattr(profile, "health_label_ru", None)
self.status.set_status(card_health, card_label)
visible_buckets = sorted(
buckets,
key=lambda bucket: (
getattr(bucket, "remaining_percent", None) is None,
float(getattr(bucket, "remaining_percent", 101.0) or 0.0),
),
)[:1]
for index, cell in enumerate(self.compact_quota_cells):
if index < len(buckets):
bucket = buckets[index]
if index < len(visible_buckets):
bucket = visible_buckets[index]
remaining = getattr(bucket, "remaining_percent", None)
color = (
Theme.STATUS_HEALTHY

View file

@ -0,0 +1,99 @@
"""Non-blocking UI adapter for the backend model-discovery cache.
The UI never discovers models synchronously and never invents model IDs. The
adapter deliberately tolerates the small naming differences used by backend
revisions so the presentation layer can be merged independently from A9.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass(frozen=True)
class CachedModels:
provider: str
models: tuple[str, ...] = ()
fetched_at: str = ""
is_stale: bool = True
unavailable_reason: str = "Список моделей ещё не получен"
def _service() -> Any:
try:
from antigravity_provider.router.model_discovery import ModelDiscoveryService
getter = getattr(ModelDiscoveryService, "get", None)
return getter() if callable(getter) else ModelDiscoveryService()
except (ImportError, AttributeError, TypeError):
return None
def _normalise(provider: str, raw: Any) -> CachedModels:
if raw is None:
return CachedModels(provider=provider)
if isinstance(raw, dict):
models = raw.get("models") or raw.get("discovered_models") or []
fetched_at = raw.get("fetched_at") or raw.get("updated_at") or raw.get("last_refresh_at") or ""
stale = raw.get("is_stale", raw.get("stale", False))
reason = raw.get("unavailable_reason") or raw.get("error") or ""
else:
models = getattr(raw, "models", None) or getattr(raw, "discovered_models", None) or []
fetched_at = (
getattr(raw, "fetched_at", None)
or getattr(raw, "updated_at", None)
or getattr(raw, "last_refresh_at", None)
or ""
)
stale = getattr(raw, "is_stale", getattr(raw, "stale", False))
reason = getattr(raw, "unavailable_reason", None) or getattr(raw, "error", None) or ""
clean = tuple(dict.fromkeys(str(model).strip() for model in models if str(model).strip()))
return CachedModels(
provider=provider,
models=clean,
fetched_at=str(fetched_at),
is_stale=bool(stale),
unavailable_reason=str(reason or ("" if clean else "Список моделей ещё не получен")),
)
def get_cached_models(provider: str) -> CachedModels:
"""Return cached models immediately; never performs provider I/O."""
service = _service()
if service is None:
return CachedModels(provider=provider, unavailable_reason="Служба обнаружения моделей ещё не подключена")
for name in ("get_cached", "get_cached_models", "get_snapshot", "get_provider_models"):
method = getattr(service, name, None)
if not callable(method):
continue
try:
return _normalise(provider, method(provider))
except Exception as exc:
return CachedModels(provider=provider, unavailable_reason=f"Кэш моделей недоступен: {exc}")
return CachedModels(provider=provider, unavailable_reason="Служба не предоставляет чтение кэша моделей")
def refresh_models_async(provider: str, on_complete: Callable[[CachedModels], None]) -> bool:
"""Request a backend background refresh without blocking the Tk thread."""
service = _service()
if service is None:
on_complete(get_cached_models(provider))
return False
for name in ("refresh_provider_async", "refresh_async", "discover_async"):
method = getattr(service, name, None)
if not callable(method):
continue
try:
result: Optional[Any] = method(provider, on_complete=lambda *_args: on_complete(get_cached_models(provider)))
return result is not False
except TypeError:
try:
result = method(provider)
return result is not False
except Exception:
break
except Exception:
break
on_complete(get_cached_models(provider))
return False

View file

@ -94,9 +94,19 @@ class _EndpointCard(HubCard):
self.progress.pack(fill="x", pady=(5, 0))
self.progress.set(0)
self._click_action: Optional[Callable[[], None]] = None
self._status = "unknown"
self._bind_click_tree(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._activate, add="+")
self.bind("<space>", self._activate, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_click_tree(self, widget: Any) -> None:
widget.configure(cursor="hand2")
widget.bind("<Button-1>", self._activate, add="+")
for child in widget.winfo_children():
self._bind_click_tree(child)
@ -109,6 +119,16 @@ class _EndpointCard(HubCard):
if self._click_action:
self._click_action()
def _hover_on(self, _event: Any = None) -> None:
if self._click_action:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
self.configure(
fg_color=Theme.SURFACE,
border_color=Theme.BORDER_ACCENT if self._status == "healthy" else Theme.BORDER,
)
def update_card(
self,
provider_key: str,
@ -119,6 +139,7 @@ class _EndpointCard(HubCard):
quota_percent: Optional[float],
status: str,
) -> None:
self._status = status
image = AssetManager.get().get_provider_image(provider_key, size=(30, 30))
self.icon.configure(image=image, text="" if image else "")
self.icon.image = image
@ -181,12 +202,8 @@ class _RouteDiagram(ctk.CTkFrame):
self.pack_propagate(False)
self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1)
self.provider_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)]
self.agent_slots = [ctk.CTkFrame(self, height=68, fg_color="transparent") for _ in range(5)]
for index, slot in enumerate(self.provider_slots):
slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30)
for index, slot in enumerate(self.agent_slots):
slot.place(relx=0.71, rely=0.01 + index * 0.185, relwidth=0.278)
self.provider_slots: list[Any] = []
self.agent_slots: list[Any] = []
self.orchestrator = _OrchestratorNode(self)
self.orchestrator.place(relx=0.51, rely=0.46, anchor="center")
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
@ -201,13 +218,33 @@ class _RouteDiagram(ctk.CTkFrame):
text_color=Theme.TEXT_MUTED,
)
self.context_status.pack()
self._left_labels = ["", "", ""]
self._right_labels = ["", "", "", "", ""]
self._left_labels: list[str] = []
self._right_labels: list[str] = []
self.bind("<Configure>", self._redraw)
def sync_slots(self, provider_count: int, agent_count: int) -> None:
"""Grow/shrink endpoint slots to match the snapshot without hard caps."""
while len(self.provider_slots) < provider_count:
self.provider_slots.append(ctk.CTkFrame(self, height=76, fg_color="transparent"))
while len(self.agent_slots) < agent_count:
self.agent_slots.append(ctk.CTkFrame(self, height=68, fg_color="transparent"))
for index, slot in enumerate(self.provider_slots):
if index >= provider_count:
slot.place_forget()
continue
y = 0.02 + index * (0.78 / max(provider_count - 1, 1))
slot.place(relx=0.012, rely=y, relwidth=0.30)
for index, slot in enumerate(self.agent_slots):
if index >= agent_count:
slot.place_forget()
continue
y = 0.01 + index * (0.80 / max(agent_count - 1, 1))
slot.place(relx=0.71, rely=y, relwidth=0.278)
self._redraw()
def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = (left + ["", "", ""])[:3]
self._right_labels = (right + ["", "", "", "", ""])[:5]
self._left_labels = list(left)
self._right_labels = list(right)
self._redraw()
def _redraw(self, _event: Any = None) -> None:
@ -215,7 +252,10 @@ class _RouteDiagram(ctk.CTkFrame):
self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71
left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
left_count = len(self._left_labels)
left_ys = [
height * (0.02 + index * (0.78 / max(left_count - 1, 1))) + 38 for index in range(left_count)
]
for index, y_pos in enumerate(left_ys):
self.canvas.create_line(
left_x,
@ -241,7 +281,10 @@ class _RouteDiagram(ctk.CTkFrame):
anchor="w",
tags="route",
)
right_ys = [height * (0.01 + index * 0.185) + 34 for index in range(5)]
right_count = len(self._right_labels)
right_ys = [
height * (0.01 + index * (0.80 / max(right_count - 1, 1))) + 34 for index in range(right_count)
]
for index, y_pos in enumerate(right_ys):
self.canvas.create_line(
center_x + 48,
@ -376,7 +419,6 @@ class DashboardView(ctk.CTkFrame):
self.on_action = on_action
self._provider_cards: Dict[str, _EndpointCard] = {}
self._agent_cards: Dict[str, _EndpointCard] = {}
self._provider_status_rows: Dict[str, _StatusRow] = {}
self._event_rows: list[_EventRow] = []
self._build()
@ -417,17 +459,25 @@ class DashboardView(ctk.CTkFrame):
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.metrics.pack(fill="x", pady=(0, 8))
for column in range(5):
for column in range(6):
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения")
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "активно: 0")
self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы")
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
self.host_metric = _KpiCard(self.metrics, "Нагрузка CPU", "Н", "нет измерения хоста")
for index, metric in enumerate(
(self.quota_metric, self.calls_metric, self.agents_metric, self.latency_metric, self.failover_metric)
(
self.quota_metric,
self.calls_metric,
self.agents_metric,
self.latency_metric,
self.failover_metric,
self.host_metric,
)
):
metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 4 else 3), sticky="nsew")
metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 5 else 3), sticky="nsew")
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x")
@ -441,46 +491,6 @@ class DashboardView(ctk.CTkFrame):
self.route_diagram = _RouteDiagram(route_card)
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
# Metrics still exist as data probes but are intentionally not shown on
# Overview. The previous right rail duplicated Accounts/Status data
# and made the useful routing canvas unnecessarily narrow.
self.realtime = HubCard(self, height=1)
self.realtime.grid_propagate(False)
ctk.CTkLabel(
self.realtime,
text="Статус в реальном времени",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=10, pady=(8, 5))
ctk.CTkLabel(self.realtime, text="ПРОВАЙДЕРЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
anchor="w", padx=10
)
self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent")
self.provider_status.pack(fill="x", padx=10, pady=(2, 5))
ctk.CTkFrame(self.realtime, height=1, fg_color=Theme.BORDER_SUBTLE).pack(fill="x", padx=10, pady=3)
ctk.CTkLabel(
self.realtime, text="СИСТЕМНЫЕ ПОКАЗАТЕЛИ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(anchor="w", padx=10, pady=(2, 1))
systems = ctk.CTkFrame(self.realtime, fg_color="transparent")
systems.pack(fill="x", padx=10)
self.system_rows = {
"cpu": _SystemRow(systems, "CPU"),
"memory": _SystemRow(systems, "Память"),
"disk": _SystemRow(systems, "Диск"),
"network": _SystemRow(systems, "Сеть"),
}
for row in self.system_rows.values():
row.pack(fill="x")
ctk.CTkFrame(self.realtime, height=1, fg_color=Theme.BORDER_SUBTLE).pack(fill="x", padx=10, pady=4)
ctk.CTkLabel(self.realtime, text="ТЕКУЩАЯ НАГРУЗКА", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
anchor="w", padx=10
)
load = ctk.CTkFrame(self.realtime, fg_color="transparent")
load.pack(fill="x", padx=10, pady=(2, 0))
self.load_rows = {key: _StatusRow(load) for key in ("active", "failovers", "errors")}
for row in self.load_rows.values():
row.pack(fill="x")
self.events_card = HubCard(self.scroll)
self.events_card.pack(fill="x", pady=(8, 0))
events_header = ctk.CTkFrame(self.events_card, fg_color="transparent")
@ -526,7 +536,8 @@ class DashboardView(ctk.CTkFrame):
if bucket and bucket.remaining_percent is not None:
remaining = float(bucket.remaining_percent)
return bucket.formatted_remaining(), remaining
return agent.active_quota_label or "Нет данных API", None
reason = getattr(quota, "unavailable_reason", None) if quota else None
return reason or agent.active_quota_label or "Нет телеметрии: роль ещё не вызывалась", None
@staticmethod
def _sync_endpoint_cards(
@ -573,13 +584,17 @@ class DashboardView(ctk.CTkFrame):
latency = global_telemetry.get("latency_p50_ms") if has_telemetry else None
failovers = global_telemetry.get("failovers_count") if has_telemetry else None
active_calls = snapshot.metrics.get("active_calls_total")
host = dict(snapshot.metrics.get("host") or {})
cpu = host.get("cpu_percent") if host.get("has_data") else None
quota_text, quota_percent = self._quota_measurement(snapshot)
self.quota_metric.set_metric(
quota_text, "реальная корзина" if quota_percent is not None else "нет измерения", quota_percent
)
self.calls_metric.set_metric(
str(total_calls) if total_calls is not None else "Н",
f"активно: {active_calls}" if isinstance(active_calls, int) else "Н",
f"активно: {active_calls}"
if isinstance(active_calls, int)
else "нет телеметрии: запросы ещё не выполнялись",
float(total_calls) if total_calls is not None else None,
)
self.agents_metric.set_metric(
@ -589,16 +604,23 @@ class DashboardView(ctk.CTkFrame):
)
self.latency_metric.set_metric(
f"{latency:.0f} мс" if latency is not None else "Н",
"P50",
"P50" if latency is not None else "нет телеметрии: запросы ещё не выполнялись",
float(latency) if latency is not None else None,
)
self.failover_metric.set_metric(
str(failovers) if failovers is not None else "Н",
"failover",
"failover" if failovers is not None else "нет телеметрии переключений",
float(failovers) if failovers is not None else None,
)
self.host_metric.set_metric(
f"{cpu:.0f}%" if cpu is not None else "Н",
"host_measurement" if cpu is not None else "psutil не вернул измерение",
float(cpu) if cpu is not None else None,
)
providers = list(snapshot.providers)[:3]
providers = list(snapshot.providers)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator]
self.route_diagram.sync_slots(len(providers), len(agents))
self._sync_endpoint_cards(
self.route_diagram.provider_slots,
self._provider_cards,
@ -615,7 +637,7 @@ class DashboardView(ctk.CTkFrame):
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"модель: Н",
"модели не обнаружены",
)
),
"Онлайн" if provider.online_count else "Недоступен",
@ -633,9 +655,8 @@ class DashboardView(ctk.CTkFrame):
orchestrator.is_active,
)
else:
self.route_diagram.orchestrator.update_node("Не назначен", "Н", False)
self.route_diagram.orchestrator.update_node("Аккаунт не подключён", "Роль не настроена", False)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5]
agent_items = []
for agent in agents:
agent_quota_text, agent_quota_percent = self._agent_quota_measurement(snapshot, agent)
@ -674,58 +695,14 @@ class DashboardView(ctk.CTkFrame):
left_labels: list[str] = []
for provider in providers:
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
left_labels.append(f"{share:.0%}" if share is not None else "")
left_labels.append(f"{share:.0%}" if share is not None else "нет телеметрии")
right_labels: list[str] = []
for agent in agents:
measured = dict(role_telemetry.get(agent.role_id) or {})
calls = measured.get("total_calls") if measured.get("has_data") else None
right_labels.append(f"{calls} выз." if calls is not None else "")
right_labels.append(f"{calls} выз." if calls is not None else "нет вызовов")
self.route_diagram.set_labels(left_labels, right_labels)
live_provider_ids = {provider.provider_id for provider in providers}
for provider_id in list(self._provider_status_rows):
if provider_id not in live_provider_ids:
self._provider_status_rows.pop(provider_id).destroy()
for provider in providers:
row = self._provider_status_rows.get(provider.provider_id)
if row is None:
row = _StatusRow(self.provider_status)
row.pack(fill="x")
self._provider_status_rows[provider.provider_id] = row
provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms")
detail = f"{provider.online_count}/{provider.connected_count} онлайн"
if provider_latency is not None:
detail += f"{provider_latency:.0f} мс"
row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning")
host = dict(snapshot.metrics.get("host") or {})
host_available = bool(host.get("has_data"))
cpu = host.get("cpu_percent") if host_available else None
memory = host.get("memory_percent") if host_available else None
disk = host.get("disk_percent") if host_available else None
network_total = sum(
value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int)
)
network_mb = network_total / (1024 * 1024) if network_total else None
self.system_rows["cpu"].update_metric(f"{cpu:.0f}%" if cpu is not None else "Н", cpu)
self.system_rows["memory"].update_metric(f"{memory:.0f}%" if memory is not None else "Н", memory)
self.system_rows["disk"].update_metric(f"{disk:.0f}%" if disk is not None else "Н", disk)
self.system_rows["network"].update_metric(
f"{network_mb:.0f} МБ" if network_mb is not None else "Н", network_mb
)
error_rate = global_telemetry.get("error_rate") if has_telemetry else None
self.load_rows["active"].update_row(
"Активные вызовы", str(active_calls) if isinstance(active_calls, int) else "Н", "healthy"
)
self.load_rows["failovers"].update_row(
"Переключения", str(failovers) if failovers is not None else "Н", "warning"
)
self.load_rows["errors"].update_row(
"Доля ошибок",
f"{error_rate:.1%}" if error_rate is not None else "Н",
"error" if error_rate else "healthy",
)
def update_events(self, events: Iterable[Any]) -> None:
items = list(events)[:5]
while len(self._event_rows) < len(items):

View file

@ -34,7 +34,7 @@ class ProviderCard(HubCard):
def update_provider(self, summary: Any) -> None:
self.title.configure(text=summary.provider_name)
self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н'}")
self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н — обнаружение ещё не запускалось'}")
self.stats.configure(
text=(
f"Онлайн {summary.online_count}/{summary.connected_count}"
@ -44,7 +44,12 @@ class ProviderCard(HubCard):
)
)
self.models.configure(
text="Модели: " + ("".join(summary.discovered_models) if summary.discovered_models else "Н")
text="Модели: "
+ (
"".join(summary.discovered_models)
if summary.discovered_models
else "Н/Д — список моделей ещё не получен"
)
)

View file

@ -69,9 +69,9 @@ class RoutingRoleWidget(HubCard):
reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason]
self.footer.configure(
text=(
f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'Н'}"
f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'переключений ещё не было'}"
if active
else "Активный узел: Н"
else "Активный узел: Н — аккаунт не подключён либо все профили недоступны"
)
)

View file

@ -161,10 +161,19 @@ class AgentCardWidget(HubCard):
)
self.menu_btn.pack(side="right")
self._bind_settings_click(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._open_settings, add="+")
self.bind("<space>", self._open_settings, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_settings_click(self, widget: Any) -> None:
if isinstance(widget, ctk.CTkButton):
return
widget.configure(cursor="hand2")
widget.bind("<Button-1>", self._open_settings, add="+")
for child in widget.winfo_children():
self._bind_settings_click(child)
@ -182,6 +191,16 @@ class AgentCardWidget(HubCard):
},
)
def _hover_on(self, _event: Any = None) -> None:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
is_orchestrator = bool(self.agent_data and self.agent_data.is_main_orchestrator)
self.configure(
fg_color=Theme.SURFACE,
border_color=Theme.BORDER_ACCENT if is_orchestrator else Theme.BORDER,
)
def update_agent(self, a: AgentViewModel):
self.agent_data = a
is_orch = a.is_main_orchestrator
@ -243,7 +262,7 @@ class AgentCardWidget(HubCard):
else Theme.TEXT_MUTED
)
)
quota_label = a.active_quota_label or "Н"
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)
@ -332,6 +351,7 @@ class TeamView(ctk.CTkFrame):
self._drag_role = ""
self._drag_origin = (0.0, 0.0)
self._drag_node_origin = (0.0, 0.0)
self._drag_moved = False
self._node_items: Dict[str, tuple[int, ...]] = {}
self._edge_items: Dict[str, tuple[int, ...]] = {}
self._event_bus = EventBus.get()
@ -491,6 +511,7 @@ class TeamView(ctk.CTkFrame):
self.selected_role = role
self.selected_edge = ""
self._drag_role = role
self._drag_moved = False
self._drag_origin = self._world(event.x, event.y)
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
self._drag_node_origin = (node.x, node.y) if node else (0.0, 0.0)
@ -505,6 +526,8 @@ class TeamView(ctk.CTkFrame):
return
x, y = self._world(event.x, event.y)
dx, dy = x - self._drag_origin[0], y - self._drag_origin[1]
if abs(dx) + abs(dy) > 1.5:
self._drag_moved = True
self._drag_origin = (x, y)
node.x += dx
node.y += dy
@ -513,6 +536,7 @@ class TeamView(ctk.CTkFrame):
def _on_release(self, _event: Any) -> None:
if self._drag_role:
clicked_role = self._drag_role
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
if node:
final_x, final_y = node.x, node.y
@ -520,6 +544,21 @@ class TeamView(ctk.CTkFrame):
self.controller.move_node(node.role_id, final_x, final_y)
self._drag_role = ""
self._set_dirty_text()
if not self._drag_moved and self.on_action:
pipeline = self._pipeline_for(clicked_role)
active_profile = pipeline.active_profile_id if pipeline else ""
active_node = next(
(item for item in list(getattr(pipeline, "nodes", []) or []) if item.profile_id == active_profile),
None,
)
self.on_action(
"agent_settings",
{
"role_id": clicked_role,
"profile_id": active_profile,
"provider": getattr(active_node, "provider", ""),
},
)
def _on_zoom(self, event: Any) -> str:
factor = 1.1 if event.delta > 0 else 0.9
@ -668,7 +707,11 @@ class TeamView(ctk.CTkFrame):
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
self.inspector_title.configure(text=node.label if node else role)
self.inspector_status.configure(
text=f"Активный: {pipeline.active_profile_id if pipeline and pipeline.active_profile_id else 'Н'}"
text=(
f"Активный: {pipeline.active_profile_id}"
if pipeline and pipeline.active_profile_id
else "Активный: Н/Д — профиль не назначен"
)
)
config = load_router_config()
policy = config.roles.get(role)
@ -686,16 +729,20 @@ class TeamView(ctk.CTkFrame):
ctk.CTkLabel(
card, text=profile_id, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(anchor="w", padx=8)
provider = profile.provider if profile else "Н"
model = live.model if live else (profile.preferred_models[0] if profile and profile.preferred_models else "Н")
provider = profile.provider if profile else "Н/Д — аккаунт не подключён"
model = live.model if live else (
profile.preferred_models[0]
if profile and profile.preferred_models
else "Н/Д — список моделей ещё не получен"
)
identity = live.account_identity if live and live.account_identity else "Аккаунт: Н"
quota = live.quota_status if live else "Н"
quota = live.quota_status if live else "Н — аккаунт не подключён"
if profile_id in self._quota_overrides:
raw_quota = self._quota_overrides[profile_id]
quota = getattr(raw_quota, "status", None) or getattr(raw_quota, "quota_status", None) or quota
if str(quota).strip().lower() in {"", "unknown", "none", "not_configured"}:
quota = "Н"
reason = live.failover_reason if live and live.failover_reason else "Н"
quota = "Н — провайдер не отдал лимиты"
reason = live.failover_reason if live and live.failover_reason else "переключений ещё не было"
ctk.CTkLabel(
card,
text=f"{provider}{model}\n{identity}\nКвота: {quota}\nFailover: {reason}",

View file

@ -335,7 +335,8 @@ def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_r
ui_root.update_idletasks()
assert view.agents_metric.val_label.cget("text") == "6"
assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text")
assert "6/6 онлайн" in view._provider_status_rows["antigravity"].detail.cget("text")
assert not hasattr(view, "realtime")
assert view.route_diagram.provider_slots[0].winfo_manager() == "place"
finally:
view.destroy()

View file

@ -38,10 +38,22 @@ def _config():
return SimpleNamespace(roles=roles, profiles=profiles)
def test_antigravity_agent_catalog_keeps_gemini_pro_choices():
choices = app_module.AGENT_MODEL_OPTIONS["antigravity"]
assert "gemini-3.1-pro" in choices
assert "gemini-2.5-pro" in choices
def test_agent_catalog_uses_backend_cache_without_literal_fallback(monkeypatch):
from antigravity_provider.router.ui import model_catalog
service = SimpleNamespace(get_cached=lambda _provider: {"models": ["provider-model-a", "provider-model-b"]})
monkeypatch.setattr(model_catalog, "_service", lambda: service)
cached = model_catalog.get_cached_models("antigravity")
assert cached.models == ("provider-model-a", "provider-model-b")
def test_agent_catalog_empty_cache_is_explicit(monkeypatch):
from antigravity_provider.router.ui import model_catalog
monkeypatch.setattr(model_catalog, "_service", lambda: None)
cached = model_catalog.get_cached_models("antigravity")
assert cached.models == ()
assert "ещё не подключена" in cached.unavailable_reason
def test_default_graph_migrates_six_roles_without_changing_chains():