diff --git a/artifacts/b6-live-overview-front.png b/artifacts/b6-live-overview-front.png new file mode 100644 index 0000000..c88dfa1 Binary files /dev/null and b/artifacts/b6-live-overview-front.png differ diff --git a/artifacts/b6-live-team-graph.png b/artifacts/b6-live-team-graph.png new file mode 100644 index 0000000..81b07c9 Binary files /dev/null and b/artifacts/b6-live-team-graph.png differ diff --git a/src/antigravity_provider/router/account_identity.py b/src/antigravity_provider/router/account_identity.py index 51ff8a9..805c166 100644 --- a/src/antigravity_provider/router/account_identity.py +++ b/src/antigravity_provider/router/account_identity.py @@ -265,12 +265,14 @@ class QuotaSnapshot: return f"Обновлено: {hrs} ч назад" def get_bucket_for_model(self, model_or_family: str) -> Optional[QuotaBucket]: - """Find the relevant quota bucket for a given model or model family.""" + """Find the most constraining quota bucket for a model family.""" target = model_or_family.lower() - # Direct family match - for b in self.buckets: - if b.model_family and b.model_family.lower() in target: - return b + matches = [b for b in self.buckets if b.model_family and b.model_family.lower() in target] + if matches: + measured = [b for b in matches if b.remaining_percent is not None] + if measured: + return min(measured, key=lambda bucket: float(bucket.remaining_percent or 0.0)) + return matches[0] # Fallback to first available bucket return self.buckets[0] if self.buckets else None diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index f133989..a907e01 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -172,6 +172,48 @@ class AutoAssigner: return None + @staticmethod + def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]: + """Persist a router profile for provider slots introduced by the UI. + + Claude and Grok were added after the original static router profile + list. Their OAuth credentials could therefore be saved successfully + while role assignment failed because the profile did not exist in the + router YAML. + """ + config = load_router_config() + if profile_id in config.profiles: + return True, "Профиль уже зарегистрирован" + defaults = { + "grok": (["grok-3", "grok-3-mini", "grok-2"], ["reasoning", "coding", "research"]), + "claude": ( + ["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"], + ["reasoning", "coding", "review"], + ), + "opencode-go": ( + ["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"], + ["coding", "research", "fast"], + ), + "openai-codex": (["gpt-4o", "o3-mini", "codex"], ["coding", "reasoning"]), + "antigravity": ( + ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"], + ["coding", "reasoning", "research", "fast"], + ), + } + models, capabilities = defaults.get(provider, (["default"], [])) + config.profiles[profile_id] = RouterProfileConfig( + profile_id=profile_id, + provider=provider, + account_id=profile_id, + capabilities=list(capabilities), + preferred_models=list(models), + enabled=True, + max_concurrency=1, + ) + if not save_router_config(config): + return False, f"Не удалось сохранить профиль '{profile_id}' в конфигурации" + return True, f"Профиль '{profile_id}' зарегистрирован" + @staticmethod def recommend_assignment(provider: str) -> Tuple[str, str, str]: """Analyze team health and return (recommended_slot, role_title_ru, reason_ru).""" diff --git a/src/antigravity_provider/router/health_tracker.py b/src/antigravity_provider/router/health_tracker.py index 53f1768..099c0bb 100644 --- a/src/antigravity_provider/router/health_tracker.py +++ b/src/antigravity_provider/router/health_tracker.py @@ -273,6 +273,36 @@ class HealthTracker: self._save_state() + def reconcile_measured_quota(self, profile_id: str, remaining_by_family: Dict[str, float]) -> bool: + """Clear stale quota-exhausted flags when the provider reports live capacity. + + A successful quota read is authoritative for quota exhaustion, but it + must not erase unrelated authentication or runtime failures. + """ + measured = {family: float(value) for family, value in remaining_by_family.items()} + if not measured: + return False + with self._lock: + record = self.get_or_create(profile_id) + changed = False + for family, remaining in measured.items(): + family_record = record.families.get(family) + if remaining > 0 and family_record and family_record.state == QUOTA_EXHAUSTED: + family_record.state = HEALTHY + family_record.reset_at = None + family_record.reason = None + family_record.last_error = None + family_record.simulated = False + changed = True + if all(value > 0 for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED: + record.overall_state = HEALTHY + record.last_error = None + record.simulated = False + changed = True + if changed: + self._save_state() + return changed + def mark_quota_exhausted( self, profile_id: str, diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index 791a425..5237928 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -42,7 +42,7 @@ for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.paren 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.router_config import load_router_config, save_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 @@ -61,7 +61,7 @@ from antigravity_provider.router.unified_health import ( STATUS_HEALTHY, ) -from antigravity_provider.router.ui.views.team_view import TeamView +from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain from antigravity_provider.router.ui.views.dashboard_view import DashboardView from antigravity_provider.router.ui.views.accounts_view import AccountsView from antigravity_provider.router.ui.views.providers_view import ProvidersView @@ -75,6 +75,24 @@ from antigravity_provider.router.ui.views.quotas_view import QuotasView 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" @@ -109,7 +127,7 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]: def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: - """Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers.""" + """Check local profile readiness without inference, OAuth, or a browser.""" config = load_router_config() pcfg = config.get_profile(profile_id) if not pcfg: @@ -119,25 +137,36 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: if not status.get("authenticated"): return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."} - if status.get("expired"): + # Ключ называется is_expired; часть провайдеров сообщает о просрочке только + # через status == "EXPIRED". Проверяем все формы: зелёная галочка на + # протухшем аккаунте — это ложь пользователю, а не мелкая неточность. + if status.get("is_expired") or status.get("expired") or status.get("status") == "EXPIRED": 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, - }, - ) + auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id) + if not auth_data: + return {"success": False, "error": "Сохранённые данные авторизации не найдены"} + adapter = get_adapter(pcfg.provider) + runtime_ready = adapter.health_check(pcfg) 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]} + if not runtime_ready: + return { + "success": False, + "duration_sec": el, + "error": "Локальный runtime провайдера недоступен; повторная авторизация не запускалась", + } + EventLogService.get().log( + "system", f"Локальная проверка профиля {profile_id} ({model}) пройдена за {el}s.", level="success" + ) + return { + "success": True, + "model": model, + "duration_sec": el, + "response": "Авторизация сохранена; runtime провайдера доступен", + } 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)} @@ -226,6 +255,18 @@ class HermesHubApp(ctk.CTk): pass self.after(50, self._refresh_data) + self.after(800, self._refresh_quotas_on_startup) + + def _refresh_quotas_on_startup(self) -> None: + """Populate measured quota cards immediately instead of after the 5-minute scheduler tick.""" + if self._shutting_down: + return + try: + from antigravity_provider.router.scheduler import HermesRefreshScheduler + + HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data)) + except Exception as exc: + logger.warning("Initial quota refresh could not start: %s", exc) def _build_layout(self): # ── Sidebar (Left) ── @@ -258,8 +299,8 @@ class HermesHubApp(ctk.CTk): ("team", "Команда", "team"), ("accounts", "Аккаунты", "accounts"), ("routing", "Маршрутизация", "routing"), - ("providers", "Провайдеры", "providers"), - ("quotas", "Квоты и лимиты", "quotas"), + ("providers", "Модели и провайдеры", "providers"), + ("quotas", "Сводка лимитов", "quotas"), ("analytics", "Аналитика", "analytics"), ("health", "Состояние", "health"), ("logs", "Журнал событий", "logs"), @@ -341,6 +382,8 @@ class HermesHubApp(ctk.CTk): text_color=Theme.STATUS_HEALTHY, ) self.status_left.pack(side="left", padx=Theme.SPACE_LG) + self.status_left.configure(cursor="hand2") + self.status_left.bind("", lambda _event: self._show_view("health"), add="+") self.global_search = ctk.CTkEntry( self.statusbar, @@ -545,7 +588,7 @@ class HermesHubApp(ctk.CTk): freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}" self.status_left.configure( - text=f"● {readiness.title_ru}", + text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}", text_color=Theme.STATUS_HEALTHY if readiness.state == "healthy" else Theme.STATUS_WARNING @@ -590,28 +633,33 @@ class HermesHubApp(ctk.CTk): 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]}"), + on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]), ) 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]}"), + on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]), ) elif action == "test": - self._show_toast(f"⚡ Тестирование {data.get('display_name', pid)}...") + self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}…", None) self._run_in_thread( lambda: do_test_profile(prov, pid), - on_success=self._show_test_result, + on_success=lambda result: self._show_test_result(result, pid), ) 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]}"), + on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]), ) elif action == "assign_role": self._open_assign_role_modal(pid, data.get("display_name", pid)) + elif action == "agent_settings": + self._open_agent_settings_modal( + data.get("role_id", ""), + pid, + ) elif action == "auto_assign_all": self._show_toast("⚡ Автоматическое распределение ролей...") self._run_in_thread( @@ -633,7 +681,13 @@ class HermesHubApp(ctk.CTk): on_complete=lambda: self.after(0, self._refresh_data), ) elif action == "edit_route": - self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.") + role_id = data.get("role_id", "") + self._open_route_editor_modal(role_id) + elif action == "open_routing": + self._show_view("routing") + routing = self._views.get("routing") + if routing and hasattr(routing, "focus_role"): + routing.focus_role(data.get("role_id", "")) elif action == "save_settings": def _settings_saved(result: Tuple[bool, str]) -> None: @@ -670,14 +724,19 @@ class HermesHubApp(ctk.CTk): justify="left", ).pack(anchor="w", pady=(0, 12)) - role_var = ctk.StringVar(value="orchestrator") + config = load_router_config() + current_role = next( + (role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain), + "orchestrator", + ) + role_var = ctk.StringVar(value=current_role) roles = [ ("orchestrator", "👑 Главный оркестратор"), - ("coder", "💻 Кодер (Code Generation)"), + ("coder-primary", "💻 Основной кодер"), + ("coder-secondary", "💻 Резервный кодер"), ("reviewer", "🔍 Ревьюер (Code Review)"), - ("researcher", "🌐 Исследователь (Search / Docs)"), - ("tester", "🧪 Тестировщик (Deterministic Tests)"), - ("general", "⚡ Агент общего назначения (Subagent)"), + ("research", "🌐 Исследователь (Search / Docs)"), + ("fast", "⚡ Быстрый агент"), ("spare", "🛡️ Резерв (Spare)"), ] @@ -693,15 +752,297 @@ class HermesHubApp(ctk.CTk): hover_color=Theme.ACCENT_HOVER, ).pack(anchor="w", padx=8, pady=3) + primary_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + modal.body, + text="Сделать основным в выбранной цепочке", + variable=primary_var, + font=Theme.font_body(), + text_color=Theme.TEXT_PRIMARY, + fg_color=Theme.ACCENT, + hover_color=Theme.ACCENT_HOVER, + ).pack(anchor="w", padx=8, pady=(12, 3)) + + result_label = ctk.CTkLabel( + modal.body, + text="", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_SECONDARY, + wraplength=440, + justify="left", + anchor="w", + ) + result_label.pack(fill="x", padx=8, pady=(8, 0)) + modal.result_label = result_label + 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() + ok, msg = AutoAssigner.assign_profile_to_role( + profile_id, + chosen, + is_primary=primary_var.get() and chosen != "spare", + ) + result_label.configure( + text=f"{'✓' if ok else '✕'} {msg}", + text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR, + ) + self._show_account_action_result(profile_id, msg, ok) + if ok: + save_button.configure(text="Готово", command=modal.destroy) + 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") + save_button = HubButton( + modal.footer, text="Применить роль", variant="primary", width=160, command=_save + ) + save_button.pack(side="right") + modal.save_button = save_button + return modal + + def _open_route_editor_modal(self, role_id: str): + """Direct, visible editor for one ordered failover chain.""" + config = load_router_config() + policy = config.roles.get(role_id) + if policy is None: + self._show_toast(f"❌ Роль '{role_id}' не найдена") + return None + modal = HubModal(self, title=f"Цепочка маршрутизации: {role_id}", width=620, height=520) + ctk.CTkLabel( + modal.body, + text="Первый профиль — основной. Ниже идут резервы в порядке переключения.", + font=Theme.font_body(), + text_color=Theme.TEXT_SECONDARY, + ).pack(anchor="w", pady=(0, 10)) + chain = list(policy.preferred_chain) + rows = ctk.CTkFrame(modal.body, fg_color="transparent") + rows.pack(fill="both", expand=True) + result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) + result.pack(fill="x", pady=(6, 0)) + + def _render() -> None: + for child in rows.winfo_children(): + child.destroy() + for index, pid in enumerate(chain): + pcfg = load_router_config().profiles.get(pid) + row = ctk.CTkFrame(rows, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) + row.pack(fill="x", pady=3) + ctk.CTkLabel( + row, + text=f"{index + 1}. {pid}", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + ).pack(side="left", padx=10, pady=8) + ctk.CTkLabel( + row, + text=(pcfg.provider if pcfg else "профиль не найден"), + font=Theme.font_caption(), + text_color=Theme.TEXT_MUTED, + ).pack(side="left", padx=6) + + def _move(delta: int, current: int = index) -> None: + target = current + delta + if 0 <= target < len(chain): + chain[current], chain[target] = chain[target], chain[current] + _render() + + def _remove(current: int = index) -> None: + chain.pop(current) + _render() + + HubButton(row, text="Удалить", variant="ghost", width=70, command=_remove).pack( + side="right", padx=(2, 8), pady=5 + ) + HubButton(row, text="↓", variant="secondary", width=34, command=lambda i=index: _move(1, i)).pack( + side="right", padx=2, pady=5 + ) + HubButton(row, text="↑", variant="secondary", width=34, command=lambda i=index: _move(-1, i)).pack( + side="right", padx=2, pady=5 + ) + + add_row = ctk.CTkFrame(modal.body, fg_color="transparent") + add_row.pack(fill="x", pady=(8, 0)) + # Keep every profile in the selector so a just-removed item can be + # re-added immediately without closing and reopening the editor. + available = list(config.profiles) + add_var = ctk.StringVar(value=available[0] if available else "Нет доступных профилей") + add_menu = ctk.CTkOptionMenu( + add_row, + values=available or ["Нет доступных профилей"], + variable=add_var, + fg_color=Theme.SURFACE_MUTED, + button_color=Theme.ACCENT, + text_color=Theme.TEXT_PRIMARY, + ) + add_menu.pack(side="left", fill="x", expand=True) + + def _add() -> None: + pid = add_var.get() + if pid in config.profiles and pid not in chain: + chain.append(pid) + _render() + + HubButton(add_row, text="+ Добавить в цепочку", variant="secondary", command=_add).pack(side="right", padx=(8, 0)) + + def _save_chain() -> None: + ok, message = persist_role_chain(role_id, chain) + result.configure( + text=f"{'✓' if ok else '✕'} {message}", + text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR, + ) + if ok: + save_button.configure(text="Готово", command=modal.destroy) + self._refresh_data() + + _render() + HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left") + save_button = HubButton(modal.footer, text="Сохранить цепочку", variant="primary", command=_save_chain) + save_button.pack(side="right") + return modal + + def _open_agent_settings_modal(self, role_id: str, profile_id: str): + """Open practical role settings from a click anywhere on an agent card.""" + from antigravity_provider.router.quota_collector import AccountQuotaService + + config = load_router_config() + role = config.roles.get(role_id) + role_labels = { + "orchestrator": "Главный оркестратор", + "coder-primary": "Кодер 1", + "coder-secondary": "Кодер 2", + "reviewer": "Ревьюер", + "research": "Исследователь", + "fast": "Быстрый агент", + } + modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=560, height=540) + ctk.CTkLabel( + modal.body, + text="Аккаунт, модель и реальные лимиты агента", + font=Theme.font_heading(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w", pady=(0, 12)) + + choices: dict[str, str] = {} + for pid, pcfg in config.profiles.items(): + status = ProfileAuthManager.get_profile_status(pcfg.provider, pid) + if not status.get("authenticated"): + continue + identity = AccountQuotaService.get().get_identity(pcfg.provider, pid).primary_identifier() + label = f"{pid} • {identity}" + choices[label] = pid + if not choices: + ctk.CTkLabel( + modal.body, + text="Нет подключённых аккаунтов. Сначала добавьте аккаунт.", + text_color=Theme.STATUS_WARNING, + ).pack(anchor="w") + HubButton(modal.footer, text="Закрыть", variant="secondary", command=modal.destroy).pack(side="right") + return modal + + selected_label = next((label for label, pid in choices.items() if pid == profile_id), next(iter(choices))) + account_var = ctk.StringVar(value=selected_label) + model_var = ctk.StringVar(value="") + + ctk.CTkLabel(modal.body, text="Аккаунт", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( + anchor="w" + ) + account_menu = ctk.CTkOptionMenu( + modal.body, + values=list(choices), + variable=account_var, + fg_color=Theme.SURFACE_MUTED, + button_color=Theme.ACCENT, + button_hover_color=Theme.ACCENT_HOVER, + text_color=Theme.TEXT_PRIMARY, + ) + account_menu.pack(fill="x", pady=(3, 10)) + + ctk.CTkLabel(modal.body, text="Модель агента", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( + anchor="w" + ) + model_menu = ctk.CTkOptionMenu( + modal.body, + values=["default"], + 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(fill="x", pady=(3, 10)) + + 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)) + quota_title = ctk.CTkLabel( + quota_card, text="Реальные лимиты", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY + ) + quota_title.pack(anchor="w", padx=12, pady=(10, 5)) + quota_rows = ctk.CTkFrame(quota_card, fg_color="transparent") + quota_rows.pack(fill="both", expand=True, padx=12, pady=(0, 8)) + + 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) + for child in quota_rows.winfo_children(): + child.destroy() + snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid) + buckets = list(snapshot.buckets) if snapshot else [] + quota_title.configure(text=f"Реальные лимиты • {current_cfg.provider}") + if not buckets: + ctk.CTkLabel( + quota_rows, text="Лимиты пока не получены", text_color=Theme.TEXT_MUTED, font=Theme.font_caption() + ).pack(anchor="w", pady=5) + for bucket in buckets[:6]: + row = ctk.CTkFrame(quota_rows, fg_color="transparent") + row.pack(fill="x", pady=3) + ctk.CTkLabel( + row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY + ).pack(side="left") + reset = bucket.formatted_reset() or "Сброс: Н/Д" + ctk.CTkLabel( + row, + text=f"{bucket.formatted_remaining()} • {reset}", + font=Theme.font_caption(), + text_color=Theme.STATUS_HEALTHY if bucket.status == "healthy" else Theme.STATUS_WARNING, + ).pack(side="right") + + account_menu.configure(command=_refresh_account_panel) + _refresh_account_panel() + result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) + result.pack(fill="x") + + def _save_agent() -> None: + current_pid = choices[account_var.get()] + current_model = model_var.get() + 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] + updated.profiles[current_pid] = profile + if role_id in updated.roles: + updated.roles[role_id].default_model = current_model + if not save_router_config(updated): + result.configure(text="✕ Не удалось сохранить модель", text_color=Theme.STATUS_ERROR) + return + ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True) + result.configure( + text=f"{'✓' if ok else '✕'} {message}", + text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR, + ) + if ok: + save_button.configure(text="Готово", command=modal.destroy) + self._refresh_data() + + HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left") + save_button = HubButton(modal.footer, text="Сохранить настройки", variant="primary", command=_save_agent) + save_button.pack(side="right") + return modal def _open_add_account_wizard(self): wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete) @@ -710,12 +1051,19 @@ class HermesHubApp(ctk.CTk): self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён") self._refresh_data() - def _show_test_result(self, result: Dict[str, Any]): + def _show_account_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> None: + view = self._views.get("accounts") + if view and hasattr(view, "show_action_result"): + view.show_action_result(profile_id, message, success) + prefix = "✅" if success is True else "❌" if success is False else "⚡" + self._show_toast(f"{prefix} {message}") + + def _show_test_result(self, result: Dict[str, Any], profile_id: str = ""): if result.get("success"): - msg = f"✓ Тест успешен | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s" + msg = f"Профиль готов • модель: {result.get('model')} • время: {result.get('duration_sec')} с" else: - msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" - self._show_toast(msg) + msg = f"Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" + self._show_account_action_result(profile_id, msg, bool(result.get("success"))) def _apply_theme(self, scheme: str) -> None: """Apply all palette tokens by rebuilding presentation widgets in place.""" @@ -768,7 +1116,7 @@ class HermesHubApp(ctk.CTk): readiness = HubStateStore.get().get_snapshot().readiness self.status_left.configure( - text=f"● {readiness.title_ru}", + text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}", text_color=Theme.STATUS_HEALTHY if readiness.state == "healthy" else Theme.STATUS_WARNING diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index e7ea635..029109a 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -15,6 +15,8 @@ import logging import os import threading import time +import urllib.error +import urllib.request from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple @@ -148,10 +150,31 @@ class AccountQuotaService: except Exception as e: logger.warning("Error fetching quota for %s/%s: %s", provider, profile_id, e) snap = self._generate_baseline_snapshot(provider, profile_id) + status = getattr(e, "status", None) + if status == 401 or "401" in str(e) or "unauthenticated" in str(e).lower(): + snap.unavailable_reason = "Авторизация истекла — обновите подключение" + else: + snap.unavailable_reason = "Провайдер не вернул данные лимитов" with self._cache_lock: self._snapshots[key] = snap + if snap.source == "provider_api": + measured_by_family: dict[str, float] = {} + for bucket in snap.buckets: + family = bucket.model_family + remaining = bucket.remaining_percent + if not family or remaining is None: + continue + measured_by_family[family] = min(measured_by_family.get(family, 100.0), float(remaining)) + if measured_by_family: + try: + from .router_engine import get_router_engine + + get_router_engine().health.reconcile_measured_quota(profile_id, measured_by_family) + except Exception as exc: + logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc) + # Notify listeners for listener in list(self._listeners): try: @@ -164,17 +187,14 @@ class AccountQuotaService: def fetch_all_configured(self, force: bool = False) -> Dict[str, QuotaSnapshot]: """Fetch quota for all configured profiles across all providers.""" results: Dict[str, QuotaSnapshot] = {} - providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"] + from antigravity_provider.router.router_config import load_router_config - for prov in providers: - # Check slots - from antigravity_provider.router.auto_assigner import AutoAssigner - slots = AutoAssigner.PRESET_SLOTS.get(prov, []) - for slot_id in slots: - auth = ProfileAuthManager.load_profile_auth(prov, slot_id) - if auth: - snap = self.fetch_account_quota(prov, slot_id, force=force) - results[f"{prov}:{slot_id}"] = snap + for profile_id, profile in load_router_config().profiles.items(): + auth = ProfileAuthManager.load_profile_auth(profile.provider, profile_id) + if not auth: + continue + snap = self.fetch_account_quota(profile.provider, profile_id, force=force) + results[f"{profile.provider}:{profile_id}"] = snap return results # ───────────────────────────────────────────────────────────── @@ -313,60 +333,171 @@ class AccountQuotaService: # ───────────────────────────────────────────────────────────── def _collect_antigravity_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: - """Collect separate Claude (5h, Weekly) and Gemini (5h, Weekly) quota pools for Google Antigravity.""" - now = _utc_now() - claude_reset_5h = now + timedelta(hours=5) - gemini_reset_5h = now + timedelta(hours=5) - weekly_reset = now + timedelta(days=7) + """Read measured per-model capacity from the official Cloud Code endpoint. - # Build separate capacity buckets - b_claude_5h = QuotaBucket( - id="antigravity.claude.5h", - display_name="Claude 5h", - model_family="claude", - used_percent=None, - remaining_percent=None, - period="5h", - reset_at=claude_reset_5h, - status="unknown", - ) - b_claude_weekly = QuotaBucket( - id="antigravity.claude.weekly", - display_name="Claude Weekly", - model_family="claude", - used_percent=None, - remaining_percent=None, - period="7d", - reset_at=weekly_reset, - status="unknown", - ) - b_gemini_5h = QuotaBucket( - id="antigravity.gemini.5h", - display_name="Gemini 5h", - model_family="gemini", - used_percent=None, - remaining_percent=None, - period="5h", - reset_at=gemini_reset_5h, - status="unknown", - ) - b_gemini_weekly = QuotaBucket( - id="antigravity.gemini.weekly", - display_name="Gemini Weekly", - model_family="gemini", - used_percent=None, - remaining_percent=None, - period="7d", - reset_at=weekly_reset, - status="unknown", - ) + The endpoint exposes one live capacity pool per model, not artificial + ``5h``/``weekly`` pairs. We therefore show the minimum remaining value + in each model family. This keeps the compact card useful while never + presenting an invented period or percentage. + """ + from antigravity_provider.cloudcode import antigravity_user_agent, load_or_onboard_project + from antigravity_provider.oauth import refresh_access_token + + now = _utc_now() + token_data = auth_data.get("token") or auth_data.get("tokens") or auth_data + if not isinstance(token_data, dict): + raise RuntimeError("В профиле Antigravity отсутствует структура OAuth-токена") + access_token = token_data.get("access_token") or token_data.get("access") + if not access_token: + raise RuntimeError("В профиле Antigravity отсутствует access token") + + refresh_token = token_data.get("refresh_token") or token_data.get("refresh") + + def _refresh_and_save() -> str: + if not refresh_token: + raise RuntimeError("OAuth-сессия истекла, refresh token отсутствует") + refreshed = refresh_access_token(str(refresh_token)) + token_data.update(refreshed) + auth_data["token"] = token_data + ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data) + return str(refreshed["access_token"]) + + expiry = _parse_datetime(token_data.get("expires_at") or token_data.get("expiry")) + if expiry and expiry <= now + timedelta(seconds=60): + access_token = _refresh_and_save() + + project_id = auth_data.get("project_id") or auth_data.get("projectId") + if not project_id: + try: + project_id = load_or_onboard_project(str(access_token)) + except Exception as exc: + if (getattr(exc, "status", None) != 401 and "401" not in str(exc)) or not refresh_token: + raise + access_token = _refresh_and_save() + project_id = load_or_onboard_project(str(access_token)) + auth_data["project_id"] = project_id + ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data) + + def _fetch(token: str, operation: str) -> dict[str, Any]: + request = urllib.request.Request( + f"https://daily-cloudcode-pa.googleapis.com/v1internal:{operation}", + data=json.dumps({"project": project_id}).encode("utf-8"), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": antigravity_user_agent(), + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8") or "{}") + + def _fetch_with_refresh(operation: str) -> dict[str, Any]: + nonlocal access_token + try: + return _fetch(str(access_token), operation) + except urllib.error.HTTPError as exc: + if exc.code != 401 or not refresh_token: + raise + access_token = _refresh_and_save() + return _fetch(access_token, operation) + + # This is the endpoint used by the Antigravity usage screen. It + # exposes the four semantic buckets: Gemini and Claude/GPT, each with + # a five-hour and weekly window. Treat it as best-effort so older + # accounts can still fall back to per-model capacity below. + try: + summary_payload = _fetch_with_refresh("retrieveUserQuotaSummary") + except Exception as exc: + logger.info("Grouped Antigravity quota unavailable for %s: %s", profile_id, exc) + summary_payload = {} + + summary_groups = summary_payload.get("groups") if isinstance(summary_payload, dict) else None + grouped: dict[tuple[str, str], QuotaBucket] = {} + if isinstance(summary_groups, list): + for group in summary_groups: + if not isinstance(group, dict): + continue + group_name = str(group.get("displayName") or group.get("description") or "").lower() + family = "gemini" if "gemini" in group_name else "claude" if "claude" in group_name else None + family_label = "Gemini" if family == "gemini" else "Claude/GPT" + if family is None: + continue + for bucket_data in group.get("buckets") or []: + if not isinstance(bucket_data, dict): + continue + remaining_fraction = bucket_data.get("remainingFraction") + window_raw = str(bucket_data.get("window") or "").lower() + window = "7d" if "week" in window_raw or window_raw == "7d" else "5h" if "5" in window_raw else "" + if not isinstance(remaining_fraction, (int, float)) or not window: + continue + window_label = "неделя" if window == "7d" else "5 часов" + grouped[(family, window)] = QuotaBucket( + id=f"antigravity.{family}.{window}", + display_name=f"{family_label} • {window_label}", + model_family=family, + remaining_percent=max(0.0, min(100.0, float(remaining_fraction) * 100.0)), + reset_at=_parse_datetime(bucket_data.get("resetTime")), + period=window, + unit="model capacity", + scope="model_family", + ) + ordered_keys = (("claude", "5h"), ("gemini", "5h"), ("claude", "7d"), ("gemini", "7d")) + if all(key in grouped for key in ordered_keys): + return QuotaSnapshot( + account_id=profile_id, + provider="antigravity", + buckets=[grouped[key] for key in ordered_keys], + fetched_at=now, + source="provider_api", + ) + + payload = _fetch_with_refresh("fetchAvailableModels") + + models = payload.get("models") or {} + if not isinstance(models, dict): + raise RuntimeError("Cloud Code вернул некорректный список моделей") + + family_values: dict[str, list[tuple[float, Optional[datetime]]]] = {"claude": [], "gemini": []} + for model_id, model_data in models.items(): + if not isinstance(model_data, dict): + continue + lowered = str(model_id).lower() + family = "claude" if "claude" in lowered else ("gemini" if "gemini" in lowered else None) + quota_info = model_data.get("quotaInfo") or {} + remaining_fraction = quota_info.get("remainingFraction") if isinstance(quota_info, dict) else None + if family is None or not isinstance(remaining_fraction, (int, float)): + continue + reset_at = _parse_datetime(quota_info.get("resetTime")) + family_values[family].append((max(0.0, min(100.0, float(remaining_fraction) * 100.0)), reset_at)) + + buckets: list[QuotaBucket] = [] + for family, display_name in (("claude", "Claude • модели"), ("gemini", "Gemini • модели")): + values = family_values[family] + if not values: + continue + remaining, reset_at = min(values, key=lambda item: item[0]) + buckets.append( + QuotaBucket( + id=f"antigravity.{family}.model_pool", + display_name=display_name, + model_family=family, + remaining_percent=remaining, + reset_at=reset_at, + period="provider", + unit="model capacity", + scope="model_family", + ) + ) + if not buckets: + raise RuntimeError("Cloud Code не вернул измеряемые квоты Claude или Gemini") return QuotaSnapshot( account_id=profile_id, provider="antigravity", - buckets=[b_claude_5h, b_claude_weekly, b_gemini_5h, b_gemini_weekly], + buckets=buckets, fetched_at=now, - source="baseline", + source="provider_api", ) def _collect_codex_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: @@ -402,44 +533,85 @@ class AccountQuotaService: ) def _collect_opencode_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: - """Collect Sliding, Weekly, and Monthly usage for OpenCode Go.""" + """Validate OpenCode Go entitlement and read usage when its API exposes it.""" now = _utc_now() - b_sliding = QuotaBucket( - id="opencode.sliding", - display_name="Скользящее", - model_family="opencode", - used_percent=None, - remaining_percent=None, - period="sliding", - status="unknown", - ) - b_weekly = QuotaBucket( - id="opencode.weekly", - display_name="Недельное", - model_family="opencode", - used_percent=None, - remaining_percent=None, - period="7d", - reset_at=now + timedelta(days=7), - status="unknown", - ) - b_monthly = QuotaBucket( - id="opencode.monthly", - display_name="Ежемесячное", - model_family="opencode", - used_percent=None, - remaining_percent=None, - period="30d", - reset_at=now + timedelta(days=30), - status="unknown", + api_key = auth_data.get("api_key") + if not api_key: + raise RuntimeError("Ключ OpenCode Go не сохранён") + + base_url = "https://opencode.ai/zen/go/v1" + + def _get(path: str) -> dict[str, Any]: + request = urllib.request.Request( + f"{base_url}{path}", + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": "hermes-hub/1.0", + }, + ) + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode("utf-8") or "{}") + + # /models is the documented read-only endpoint and confirms that the + # key is accepted without spending a request from the user's limit. + _get("/models") + usage: dict[str, Any] = {} + unavailable_reason: Optional[str] = None + try: + usage = _get("/usage") + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + if exc.code == 403 and "subscription required" in raw.lower(): + unavailable_reason = "Для этого ключа не активна подписка OpenCode Go" + elif exc.code in (403, 404): + unavailable_reason = "OpenCode Go не предоставляет остаток через публичный API" + else: + raise + + def _metric(*names: str) -> dict[str, Any]: + for name in names: + value = usage.get(name) + if isinstance(value, dict): + return value + return {} + + buckets: list[QuotaBucket] = [] + specs = ( + ("5h", "Лимит 5 часов", 12, _metric("five_hour", "fiveHour", "sliding")), + ("7d", "Недельный лимит", 30, _metric("weekly", "seven_day", "sevenDay")), + ("30d", "Месячный лимит", 60, _metric("monthly", "thirty_day", "thirtyDay")), ) + for period, label, limit_value, metric in specs: + remaining_percent = metric.get("remaining_percent", metric.get("remainingPercentage")) + remaining_absolute = metric.get("remaining", metric.get("remaining_amount")) + used_absolute = metric.get("used", metric.get("used_amount")) + reset_at = _parse_datetime(metric.get("reset_at") or metric.get("resetTime")) + buckets.append( + QuotaBucket( + id=f"opencode.{period}", + display_name=label, + model_family="opencode", + remaining_percent=float(remaining_percent) if isinstance(remaining_percent, (int, float)) else None, + used_absolute=int(used_absolute) if isinstance(used_absolute, (int, float)) else None, + remaining_absolute=( + int(remaining_absolute) if isinstance(remaining_absolute, (int, float)) else None + ), + limit_absolute=limit_value, + reset_at=reset_at, + period=period, + unit="USD", + scope="account", + ) + ) return QuotaSnapshot( account_id=profile_id, provider="opencode-go", - buckets=[b_sliding, b_weekly, b_monthly], + buckets=buckets, fetched_at=now, - source="baseline", + source="provider_api", + unavailable_reason=unavailable_reason, ) def _collect_claude_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index 3de4673..7badbc8 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -24,9 +24,23 @@ from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.profile_manager import ProfileAuthManager +from antigravity_provider.router.router_config import load_router_config from antigravity_provider.router.unified_health import EventLogService +def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]: + """Keep existing chain rank or route a newly introduced profile slot.""" + config = load_router_config() + assigned_role = next( + (role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain), + "", + ) + if assigned_role: + return True, f"Профиль уже входит в цепочку '{assigned_role}'" + _display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id) + return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary") + + class AddAccountWizard(HubModal): """4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment.""" @@ -198,9 +212,10 @@ class AddAccountWizard(HubModal): self._clear_body() self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи") - self.target_slot = ( - AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1" - ) + self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or "" + if not self.target_slot: + self._show_no_free_slot() + return if self.selected_provider == "antigravity": self._build_antigravity_oauth_flow() @@ -222,6 +237,36 @@ class AddAccountWizard(HubModal): else: self._build_api_key_flow() + def _show_no_free_slot(self) -> None: + """Stop before OAuth when the configured provider has no real free slot.""" + provider_labels = { + "antigravity": "Google Antigravity", + "openai-codex": "OpenAI Codex", + "opencode-go": "OpenCode Go", + "claude": "Claude", + "grok": "Grok", + } + card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED) + card.pack(fill="x", pady=20) + ctk.CTkLabel( + card, + text="Нет свободного слота", + font=Theme.font_heading(), + text_color=Theme.STATUS_WARNING, + ).pack(anchor="w", padx=16, pady=(14, 6)) + ctk.CTkLabel( + card, + text=( + f"Все слоты провайдера {provider_labels.get(self.selected_provider, self.selected_provider)} заняты.\n" + "Освободите один слот или удалите неиспользуемый аккаунт, затем повторите подключение." + ), + font=Theme.font_body(), + text_color=Theme.TEXT_PRIMARY, + justify="left", + wraplength=520, + ).pack(anchor="w", padx=16, pady=(0, 14)) + self._build_step_2_footer() + # ───────────────────────────────────────────────────────────── # GOOGLE ANTIGRAVITY OAUTH FLOW # ───────────────────────────────────────────────────────────── @@ -470,10 +515,10 @@ class AddAccountWizard(HubModal): ctk.CTkLabel( auth_card, - text="Ссылка для входа в OpenAI (ChatGPT):", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", padx=10, pady=(6, 2)) + text="1. Откройте ссылку — кнопка ниже откроет страницу OpenAI", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w", padx=10, pady=(8, 2)) url_row = ctk.CTkFrame(auth_card, fg_color="transparent") url_row.pack(fill="x", padx=10, pady=(0, 6)) @@ -490,9 +535,9 @@ class AddAccountWizard(HubModal): HubButton( url_row, - text="📋", + text="Копировать ссылку", variant="secondary", - width=40, + width=130, height=32, command=self._copy_codex_url, ).pack(side="right") @@ -502,7 +547,7 @@ class AddAccountWizard(HubModal): ctk.CTkLabel( code_card, - text="Код подтверждения:", + text="2. Введите на странице код:", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY, ).pack(side="left", padx=(0, 8)) @@ -510,7 +555,7 @@ class AddAccountWizard(HubModal): self.codex_code_lbl = ctk.CTkLabel( code_card, text="...", - font=Theme.font_mono_bold(), + font=("Consolas", 18, "bold"), text_color=Theme.ACCENT, ) self.codex_code_lbl.pack(side="left", padx=(0, 8)) @@ -535,6 +580,13 @@ class AddAccountWizard(HubModal): command=self._open_codex_browser, ).pack(side="left", padx=(0, 8)) + ctk.CTkLabel( + auth_card, + text="3. Подтвердите доступ — мастер продолжит автоматически", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w", padx=10, pady=(0, 8)) + # Manual Fallback Card manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) manual_card.pack(fill="x", pady=(0, 8)) @@ -641,6 +693,14 @@ class AddAccountWizard(HubModal): def _open_codex_browser(self): if self.codex_url: + self._copy_codex_code() + self.codex_status_lbl.configure( + text=( + "Код скопирован. Вставьте его на открывшейся странице OpenAI; " + "затем вернитесь сюда — Hub продолжит автоматически." + ), + text_color=Theme.TEXT_SECONDARY, + ) webbrowser.open(self.codex_url) def _handle_codex_manual_submit(self): @@ -745,9 +805,9 @@ class AddAccountWizard(HubModal): HubButton( url_row, - text="📋", + text="Копировать ссылку", variant="secondary", - width=40, + width=130, height=32, command=self._copy_claude_url, ).pack(side="right") @@ -906,10 +966,10 @@ class AddAccountWizard(HubModal): ctk.CTkLabel( auth_card, - text="Ссылка для входа в xAI Grok:", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", padx=10, pady=(6, 2)) + text="1. Откройте ссылку — кнопка ниже откроет страницу xAI", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w", padx=10, pady=(8, 2)) url_row = ctk.CTkFrame(auth_card, fg_color="transparent") url_row.pack(fill="x", padx=10, pady=(0, 6)) @@ -938,7 +998,7 @@ class AddAccountWizard(HubModal): ctk.CTkLabel( code_card, - text="Код подтверждения:", + text="2. Введите на странице код:", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY, ).pack(side="left", padx=(0, 8)) @@ -946,7 +1006,7 @@ class AddAccountWizard(HubModal): self.grok_code_lbl = ctk.CTkLabel( code_card, text="...", - font=Theme.font_mono_bold(), + font=("Consolas", 18, "bold"), text_color=Theme.PROVIDER_GROK, ) self.grok_code_lbl.pack(side="left", padx=(0, 8)) @@ -971,6 +1031,13 @@ class AddAccountWizard(HubModal): command=self._open_grok_browser, ).pack(side="left", padx=(0, 8)) + ctk.CTkLabel( + auth_card, + text="3. Подтвердите доступ — мастер продолжит автоматически", + font=Theme.font_body_bold(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w", padx=10, pady=(0, 8)) + # Manual Entry Card manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) manual_card.pack(fill="x", pady=(0, 8)) @@ -1077,6 +1144,14 @@ class AddAccountWizard(HubModal): def _open_grok_browser(self): if self.grok_url: + self._copy_grok_code() + self.grok_status_lbl.configure( + text=( + "Код скопирован. Вставьте его на открывшейся странице xAI; " + "затем вернитесь сюда — Hub продолжит автоматически." + ), + text_color=Theme.TEXT_SECONDARY, + ) webbrowser.open(self.grok_url) def _handle_grok_manual_submit(self): @@ -1223,13 +1298,14 @@ class AddAccountWizard(HubModal): entry_row, placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."), font=Theme.font_mono(), - show="*", + show="" if self.selected_provider == "opencode-go" else "*", height=38, fg_color=Theme.PRIMARY, border_color=Theme.BORDER, text_color=Theme.TEXT_PRIMARY, ) self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) + self.key_entry.focus_set() HubButton( entry_row, @@ -1305,12 +1381,19 @@ class AddAccountWizard(HubModal): def _paste_into_entry(self, entry_widget: HubEntry): try: - content = self.clipboard_get().strip() + content = entry_widget.clipboard_get().strip() if content: entry_widget.delete(0, "end") entry_widget.insert(0, content) - except Exception: - pass + entry_widget.focus_set() + entry_widget.icursor("end") + if hasattr(self, "key_status_lbl") and entry_widget is getattr(self, "key_entry", None): + self.key_status_lbl.configure(text="✓ Ключ вставлен. Нажмите «Проверить и продолжить».") + elif hasattr(self, "key_status_lbl"): + self.key_status_lbl.configure(text="Буфер обмена пуст.") + except Exception as exc: + if hasattr(self, "key_status_lbl"): + self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}") # ═══════════════════════════════════════════════════════════════ # STEP 3: Validation & Identity @@ -1441,6 +1524,15 @@ class AddAccountWizard(HubModal): anchor="w", ).pack(fill="x", pady=8) + self.finish_status_lbl = ctk.CTkLabel( + self.body, + text="Нажмите «Завершить подключение», чтобы сохранить роль и обновить Hub.", + font=Theme.font_caption(), + text_color=Theme.TEXT_SECONDARY, + anchor="w", + ) + self.finish_status_lbl.pack(fill="x", pady=(0, 4)) + HubButton( self.footer, text="✓ Завершить подключение", @@ -1449,13 +1541,40 @@ class AddAccountWizard(HubModal): ).pack(side="right", padx=10, pady=10) def _finish(self): - # Ни журналирование, ни обратный вызов не должны мешать закрытию окна: - # исключение здесь оставляло мастер открытым без единого признака ошибки. + if not self.target_slot: + self.finish_status_lbl.configure( + text="❌ Подключение невозможно: свободный слот не найден.", + text_color=Theme.STATUS_ERROR, + ) + return + profile_ok, profile_message = AutoAssigner.ensure_profile_definition( + self.selected_provider, + self.target_slot, + ) + if not profile_ok: + self.finish_status_lbl.configure(text=f"❌ {profile_message}", text_color=Theme.STATUS_ERROR) + return + # Most built-in slots already occur in a default chain. Custom or + # repaired configs may not, so completion makes that invariant explicit + # without reordering a slot that is already assigned. + ok, message = ensure_profile_in_routing(self.target_slot) + if not ok: + self.finish_status_lbl.configure(text=f"❌ {message}", text_color=Theme.STATUS_ERROR) + return + + # Всё, что проверяемо, уже проверено выше и сообщает об ошибке в самом + # окне. Дальше идут побочные действия, и ни одно из них не должно + # мешать закрытию: исключение здесь оставляло мастер открытым без + # единого признака ошибки, потому что под pythonw трейсбека не видно. try: + # A reused slot can carry cooldown from an older account. Fresh OAuth + # credentials must start with fresh health state. + from antigravity_provider.router.router_engine import get_router_engine + + get_router_engine().health.clear_cooldown(self.target_slot) EventLogService.get().log( "account", - f"Подключен аккаунт {self.selected_provider}: {self.discovered_identity}", - details=f"Слот: {self.target_slot}", + f"Подключен аккаунт {self.selected_provider}; слот {self.target_slot}; роль сохранена.", level="success", ) except Exception: diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index 418865f..e2284c8 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -481,7 +481,8 @@ class HubModal(ctk.CTkToplevel): self, corner_radius=Theme.RADIUS_LG, border_color=Theme.BORDER_ACCENT, - fg_color=Theme.DARK, + # Contrast-safe in dark, hybrid and light palettes. + fg_color=Theme.SURFACE, ) self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG) @@ -867,7 +868,7 @@ class AccountCardWidget(HubCard): self.profile_id = profile_id self.profile_model: Any = None self.on_action = on_action - self.compact = compact + self.compact = True self._quota_widgets: Dict[str, QuotaBucketWidget] = {} self.widgets_created = 0 self.widgets_destroyed = 0 @@ -876,8 +877,8 @@ 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.toggle = IconButton(top, text="▾", command=self.toggle_compact) - self.toggle.pack(side="right") + self.profile_mark = ctk.CTkLabel(top, text="◇", font=Theme.font_caption(), text_color=Theme.TEXT_ACCENT) + 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) @@ -887,6 +888,47 @@ class AccountCardWidget(HubCard): self.status = StatusBadge(self, status) self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) + self.compact_quota = ctk.CTkFrame(self, fg_color="transparent") + self.compact_quota_cells: list[dict[str, Any]] = [] + for index in range(4): + 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") + title.pack(fill="x") + value = ctk.CTkLabel(cell, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, anchor="e") + value.pack(fill="x") + progress = ctk.CTkProgressBar( + cell, + height=5, + corner_radius=3, + fg_color=Theme.SURFACE_MUTED, + progress_color=Theme.STATUS_HEALTHY, + ) + progress.pack(fill="x", pady=(2, 1)) + progress.set(0) + reset = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, anchor="w") + reset.pack(fill="x") + self.compact_quota_cells.append( + {"frame": cell, "title": title, "value": value, "progress": progress, "reset": reset} + ) + self.compact_quota.grid_columnconfigure(index % 2, weight=1) + self.compact_actions = ctk.CTkFrame(self, fg_color="transparent") + for text, action in ( + ("⚡", "test"), + ("★", "set_main"), + ("♛", "set_orchestrator"), + ("Роль", "assign_role"), + ("↻", "refresh_account"), + ): + ActionButton( + self.compact_actions, + text=text, + variant="ghost", + width=48, + height=Theme.HEIGHT_BTN_SM, + command=lambda action_name=action: self._trigger(action_name), + ).pack(side="left", padx=(0, Theme.SPACE_XS)) + self.details = ctk.CTkFrame(self, fg_color="transparent") self.details.pack(fill="x") self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent") @@ -896,6 +938,15 @@ class AccountCardWidget(HubCard): ) self.actions = ctk.CTkFrame(self.details, fg_color="transparent") self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) + self.action_feedback = ctk.CTkLabel( + self.actions, + text="", + font=Theme.font_caption(), + text_color=Theme.TEXT_SECONDARY, + wraplength=330, + justify="left", + anchor="w", + ) self.action_buttons: Dict[str, ActionButton] = {} management = ctk.CTkFrame(self.actions, fg_color="transparent") management.pack(fill="x", pady=(0, Theme.SPACE_XS)) @@ -926,7 +977,7 @@ class AccountCardWidget(HubCard): width=72, command=lambda: self._trigger("delete_credentials"), ).pack(side="right") - self.set_compact(compact) + self.set_compact(True) @staticmethod def resolve_identity(profile: Any) -> str: @@ -943,18 +994,28 @@ class AccountCardWidget(HubCard): def _trigger(self, action: str) -> None: if self.on_action and self.profile_model is not None: + self.set_action_feedback("Выполняется…", None) self.on_action(action, self.profile_model) + def set_action_feedback(self, message: str, success: Optional[bool]) -> None: + """Show an action result where the user initiated it.""" + color = Theme.TEXT_SECONDARY + if success is True: + color = Theme.STATUS_HEALTHY + elif success is False: + color = Theme.STATUS_ERROR + self.action_feedback.configure(text=message, text_color=color) + self.action_feedback.pack(fill="x", pady=(0, Theme.SPACE_XS), before=self.actions.winfo_children()[1]) + def toggle_compact(self) -> None: - self.set_compact(not self.compact) + # Account cards intentionally have one fixed Cockpit-style layout. + self.set_compact(True) def set_compact(self, compact: bool) -> None: - self.compact = compact - self.toggle.configure(text="▸" if compact else "▾") - if compact: - self.details.pack_forget() - else: - self.details.pack(fill="x") + 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)) def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None: self.profile_model = profile @@ -974,12 +1035,53 @@ class AccountCardWidget(HubCard): ) else: self.plan_badge.pack_forget() - self.status.set_status(profile.health_state, getattr(profile, "health_label_ru", None)) self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER) snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None) buckets = list(getattr(snapshot, "buckets", None) or []) estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True + unavailable_reason = getattr(snapshot, "unavailable_reason", None) if snapshot else None + measured_remaining = [ + float(bucket.remaining_percent) + for bucket in buckets + if getattr(bucket, "remaining_percent", None) is not None + ] + if measured_remaining and not estimated: + if all(remaining <= 0 for remaining in measured_remaining): + card_health, card_label = "quota_exhausted", "Квота исчерпана" + elif any(remaining <= 0 for remaining in measured_remaining): + card_health, card_label = "warning", "Часть квот исчерпана" + else: + card_health, card_label = "healthy", "Работает" + else: + card_health = profile.health_state + card_label = getattr(profile, "health_label_ru", None) + self.status.set_status(card_health, card_label) + for index, cell in enumerate(self.compact_quota_cells): + if index < len(buckets): + bucket = buckets[index] + remaining = getattr(bucket, "remaining_percent", None) + color = ( + Theme.STATUS_HEALTHY + if getattr(bucket, "status", "unknown") == "healthy" + else Theme.STATUS_WARNING + if getattr(bucket, "status", "unknown") == "warning" + else Theme.TEXT_MUTED + ) + detail = bucket.formatted_remaining() + if detail == "Н/Д" and unavailable_reason: + limit = getattr(bucket, "limit_absolute", None) + unit = getattr(bucket, "unit", None) + prefix = f"Лимит ${limit} • " if limit is not None and unit == "USD" else "" + detail = f"{prefix}{unavailable_reason}" + cell["title"].configure(text=bucket.display_name) + cell["value"].configure(text=detail, text_color=color) + cell["progress"].configure(progress_color=color) + cell["progress"].set(float(remaining) / 100.0 if remaining is not None else 0) + cell["reset"].configure(text=bucket.formatted_reset() or "Период указан провайдером") + cell["frame"].grid() + else: + cell["frame"].grid_remove() seen: set[str] = set() for bucket in buckets: key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket")) diff --git a/src/antigravity_provider/router/ui/routing_graph.py b/src/antigravity_provider/router/ui/routing_graph.py new file mode 100644 index 0000000..6939411 --- /dev/null +++ b/src/antigravity_provider/router/ui/routing_graph.py @@ -0,0 +1,273 @@ +"""Versioned presentation model for the Team routing graph. + +The router YAML remains the source of truth for profiles and role chains. This +module stores only topology/layout metadata next to it and applies profile +assignments through :class:`AutoAssigner`. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import copy +import json +from pathlib import Path +from typing import Any, Iterable, Optional +from uuid import uuid4 + +from antigravity_provider import paths +from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.router_config import RouterConfig, load_router_config + +SCHEMA_VERSION = 1 +EDGE_TYPES = ("PRIMARY", "FALLBACK", "DELEGATE") +CANONICAL_ROLES = ("orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast") +ROLE_LABELS = { + "orchestrator": "Оркестратор", + "coder-primary": "Основной кодер", + "coder-secondary": "Резервный кодер", + "reviewer": "Ревьюер", + "research": "Исследователь", + "fast": "Быстрый агент", +} + + +@dataclass +class GraphNode: + role_id: str + x: float + y: float + label: str = "" + + +@dataclass +class GraphEdge: + source: str + target: str + edge_type: str = "DELEGATE" + profile_id: str = "" + edge_id: str = field(default_factory=lambda: uuid4().hex[:12]) + + +@dataclass +class GraphIssue: + code: str + message: str + node_id: str = "" + edge_id: str = "" + + +@dataclass +class RoutingGraph: + schema_version: int = SCHEMA_VERSION + nodes: list[GraphNode] = field(default_factory=list) + edges: list[GraphEdge] = field(default_factory=list) + zoom: float = 1.0 + viewport_x: float = 0.0 + viewport_y: float = 0.0 + + def clone(self) -> "RoutingGraph": + return copy.deepcopy(self) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, value: dict[str, Any]) -> "RoutingGraph": + if int(value.get("schema_version", 0)) != SCHEMA_VERSION: + raise ValueError("Unsupported routing graph schema") + return cls( + schema_version=SCHEMA_VERSION, + nodes=[GraphNode(**item) for item in value.get("nodes", [])], + edges=[GraphEdge(**item) for item in value.get("edges", [])], + zoom=float(value.get("zoom", 1.0)), + viewport_x=float(value.get("viewport_x", 0.0)), + viewport_y=float(value.get("viewport_y", 0.0)), + ) + + +def default_graph(config: Optional[RouterConfig] = None) -> RoutingGraph: + """Migrate current configured roles without touching their chains.""" + config = config or load_router_config() + roles = [role for role in CANONICAL_ROLES if role in config.roles] + roles.extend(role for role in config.roles if role not in roles) + nodes: list[GraphNode] = [] + for index, role_id in enumerate(roles): + if role_id == "orchestrator": + x, y = 90.0, 240.0 + else: + slot = index - (1 if "orchestrator" in roles else 0) + x, y = 390.0 + (slot // 3) * 300.0, 80.0 + (slot % 3) * 160.0 + nodes.append(GraphNode(role_id, x, y, ROLE_LABELS.get(role_id, role_id))) + root = "orchestrator" if "orchestrator" in roles else (roles[0] if roles else "") + edges = [GraphEdge(root, role, "DELEGATE") for role in roles if root and role != root] + return RoutingGraph(nodes=nodes, edges=edges) + + +class RoutingGraphStore: + def __init__(self, path: Optional[Path] = None): + self.path = path or (paths.get_hermes_home() / "routing_graph.json") + + def load(self, config: Optional[RouterConfig] = None) -> RoutingGraph: + try: + return RoutingGraph.from_dict(json.loads(self.path.read_text(encoding="utf-8"))) + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): + return default_graph(config) + + def save(self, graph: RoutingGraph) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(graph.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(self.path) + + +def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -> list[GraphIssue]: + config = config or load_router_config() + issues: list[GraphIssue] = [] + node_ids = [node.role_id for node in graph.nodes] + node_set = set(node_ids) + for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}): + issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id)) + if "orchestrator" not in node_set: + issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора")) + for node in graph.nodes: + policy = config.roles.get(node.role_id) + if policy is None: + issues.append(GraphIssue("missing-role", f"Роль {node.role_id} отсутствует в конфигурации", node.role_id)) + elif not policy.preferred_chain: + issues.append(GraphIssue("empty-chain", f"У роли {node.label or node.role_id} нет профилей", node.role_id)) + else: + for profile_id in policy.preferred_chain: + if profile_id not in config.profiles: + issues.append( + GraphIssue("missing-profile", f"Профиль {profile_id} не существует", node.role_id) + ) + seen_edges: set[tuple[str, str, str, str]] = set() + adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_set} + for edge in graph.edges: + key = (edge.source, edge.target, edge.edge_type, edge.profile_id) + if key in seen_edges: + issues.append(GraphIssue("duplicate-edge", "Дублирующая связь", edge_id=edge.edge_id)) + seen_edges.add(key) + if edge.edge_type not in EDGE_TYPES: + issues.append(GraphIssue("edge-type", f"Неизвестный тип {edge.edge_type}", edge_id=edge.edge_id)) + if edge.source not in node_set or edge.target not in node_set: + issues.append(GraphIssue("dangling-edge", "Связь ведёт к отсутствующей роли", edge_id=edge.edge_id)) + continue + adjacency[edge.source].append(edge.target) + if edge.profile_id and edge.profile_id not in config.profiles: + issues.append(GraphIssue("missing-profile", f"Профиль {edge.profile_id} не существует", edge.target, edge.edge_id)) + + visited: set[str] = set() + active: set[str] = set() + + def visit(role_id: str) -> None: + if role_id in active: + issues.append(GraphIssue("cycle", f"Цикл маршрутизации через {role_id}", role_id)) + return + if role_id in visited: + return + visited.add(role_id) + active.add(role_id) + for target in adjacency.get(role_id, []): + visit(target) + active.remove(role_id) + + if "orchestrator" in node_set: + visit("orchestrator") + for role_id in sorted(node_set - visited): + issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id)) + return issues + + +class RoutingGraphController: + """Undoable graph editor whose assignments go through AutoAssigner.""" + + def __init__(self, store: Optional[RoutingGraphStore] = None): + self.store = store or RoutingGraphStore() + self.graph = self.store.load() + self._undo: list[RoutingGraph] = [] + self._redo: list[RoutingGraph] = [] + self.dirty = False + + def _checkpoint(self) -> None: + self._undo.append(self.graph.clone()) + self._undo = self._undo[-50:] + self._redo.clear() + self.dirty = True + + def move_node(self, role_id: str, x: float, y: float) -> None: + node = next((item for item in self.graph.nodes if item.role_id == role_id), None) + if node and (node.x, node.y) != (x, y): + self._checkpoint() + node.x, node.y = x, y + + def add_edge(self, source: str, target: str, edge_type: str, profile_id: str = "") -> tuple[bool, str]: + if edge_type not in EDGE_TYPES: + return False, "Неизвестный тип связи" + self._checkpoint() + self.graph.edges.append(GraphEdge(source, target, edge_type, profile_id)) + if profile_id and edge_type in {"PRIMARY", "FALLBACK"}: + ok, message = AutoAssigner.assign_profile_to_role(profile_id, target, edge_type == "PRIMARY") + if not ok: + self.undo() + return False, message + return True, "Связь добавлена" + + def delete_edge(self, edge_id: str) -> None: + if any(edge.edge_id == edge_id for edge in self.graph.edges): + self._checkpoint() + self.graph.edges = [edge for edge in self.graph.edges if edge.edge_id != edge_id] + + def set_edge_type( + self, edge_id: str, edge_type: str, profile_id: Optional[str] = None + ) -> tuple[bool, str]: + edge = next((item for item in self.graph.edges if item.edge_id == edge_id), None) + if edge is None or edge_type not in EDGE_TYPES: + return False, "Связь не найдена" + self._checkpoint() + edge.edge_type = edge_type + if profile_id is not None: + edge.profile_id = profile_id + if edge.profile_id and edge_type in {"PRIMARY", "FALLBACK"}: + ok, message = AutoAssigner.assign_profile_to_role(edge.profile_id, edge.target, edge_type == "PRIMARY") + if not ok: + self.undo() + return ok, message + return True, "Тип связи изменён" + + def auto_layout(self) -> None: + self._checkpoint() + root = next((node for node in self.graph.nodes if node.role_id == "orchestrator"), None) + if root: + root.x, root.y = 80.0, 240.0 + others = [node for node in self.graph.nodes if node.role_id != "orchestrator"] + for index, node in enumerate(others): + node.x = 390.0 + (index // 4) * 290.0 + node.y = 55.0 + (index % 4) * 135.0 + + def undo(self) -> bool: + if not self._undo: + return False + self._redo.append(self.graph.clone()) + self.graph = self._undo.pop() + self.dirty = True + return True + + def redo(self) -> bool: + if not self._redo: + return False + self._undo.append(self.graph.clone()) + self.graph = self._redo.pop() + self.dirty = True + return True + + def save(self) -> list[GraphIssue]: + issues = validate_graph(self.graph) + if not issues: + self.store.save(self.graph) + self.dirty = False + return issues + + def role_chain(self, role_id: str) -> Iterable[str]: + policy = load_router_config().roles.get(role_id) + return tuple(policy.preferred_chain if policy else ()) diff --git a/src/antigravity_provider/router/ui/views/accounts_view.py b/src/antigravity_provider/router/ui/views/accounts_view.py index d1d90f7..13d0e34 100644 --- a/src/antigravity_provider/router/ui/views/accounts_view.py +++ b/src/antigravity_provider/router/ui/views/accounts_view.py @@ -29,7 +29,7 @@ PROVIDER_LABELS = { class ProviderGroup(ctk.CTkFrame): - """Collapsible provider section that retains its account cards while hidden.""" + """Fixed provider section with always-visible Cockpit-style cards.""" def __init__(self, master: Any, provider: str): super().__init__(master, fg_color="transparent") @@ -37,21 +37,13 @@ class ProviderGroup(ctk.CTkFrame): self.collapsed = False self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM) self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS)) - self.toggle = ActionButton( - self.header, - text="▾", - variant="ghost", - width=34, - command=self.toggle_collapsed, - ) - self.toggle.pack(side="left", padx=(Theme.SPACE_SM, 0)) self.title = ctk.CTkLabel( self.header, text=PROVIDER_LABELS.get(provider, provider), font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY, ) - self.title.pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM) + self.title.pack(side="left", padx=Theme.SPACE_MD, pady=Theme.SPACE_SM) self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) self.count.pack(side="right", padx=Theme.SPACE_MD) self.body = ctk.CTkFrame(self, fg_color="transparent") @@ -60,12 +52,8 @@ class ProviderGroup(ctk.CTkFrame): self.body.grid_columnconfigure(column, weight=1) def toggle_collapsed(self) -> None: - self.collapsed = not self.collapsed - self.toggle.configure(text="▸" if self.collapsed else "▾") - if self.collapsed: - self.body.pack_forget() - else: - self.body.pack(fill="x") + self.collapsed = False + self.body.pack(fill="x") class AccountsView(ctk.CTkFrame): @@ -91,7 +79,7 @@ class AccountsView(ctk.CTkFrame): header = SectionHeader( self, title="Аккаунты и квоты", - subtitle="Реальные идентичности, независимые лимитные корзины и резервные роли", + subtitle="Компактные карточки аккаунтов, реальные остатки и быстрые действия", action_text="+ Добавить аккаунт", action_cmd=lambda: self._emit("add_account", {}), ) @@ -220,6 +208,7 @@ class AccountsView(ctk.CTkFrame): profile.profile_id, AccountCardWidget.resolve_identity(profile), profile.provider_display_name, + compact=True, on_action=self._emit, ) self._cards[profile.profile_id] = card @@ -267,3 +256,11 @@ class AccountsView(ctk.CTkFrame): "quota_widgets_created": sum(card.widgets_created for card in self._cards.values()), "quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()), } + + def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool: + """Keep account action feedback next to the originating controls.""" + card = self._cards.get(profile_id) + if card is None: + return False + card.set_action_feedback(message, success) + return True diff --git a/src/antigravity_provider/router/ui/views/dashboard_view.py b/src/antigravity_provider/router/ui/views/dashboard_view.py index f8c8876..40e1954 100644 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ b/src/antigravity_provider/router/ui/views/dashboard_view.py @@ -9,7 +9,7 @@ import customtkinter as ctk from antigravity_provider.router.state_store import HubSnapshot from antigravity_provider.router.ui.assets import AssetManager -from antigravity_provider.router.ui.components import HubCard +from antigravity_provider.router.ui.components import HubButton, HubCard from antigravity_provider.router.ui.theme import Theme @@ -83,16 +83,31 @@ class _EndpointCard(HubCard): self.subtitle.pack(anchor="w", pady=(0, 1)) self.status = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY) self.status.pack(anchor="w") - quota = ctk.CTkFrame(self, fg_color="transparent", width=58) + quota = ctk.CTkFrame(self, fg_color="transparent", width=92) quota.pack(side="right", fill="y", padx=(3, 8), pady=8) quota.pack_propagate(False) - self.quota_label = ctk.CTkLabel(quota, text="Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY) + self.quota_label = ctk.CTkLabel(quota, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY) self.quota_label.pack(anchor="e") self.progress = ctk.CTkProgressBar( quota, height=4, corner_radius=2, progress_color=Theme.STATUS_HEALTHY, fg_color=Theme.SURFACE_MUTED ) self.progress.pack(fill="x", pady=(5, 0)) self.progress.set(0) + self._click_action: Optional[Callable[[], None]] = None + self._bind_click_tree(self) + + def _bind_click_tree(self, widget: Any) -> None: + widget.bind("", self._activate, add="+") + for child in widget.winfo_children(): + self._bind_click_tree(child) + + def set_click_action(self, action: Optional[Callable[[], None]]) -> None: + self._click_action = action + self.configure(cursor="hand2" if action else "arrow") + + def _activate(self, _event: Any = None) -> None: + if self._click_action: + self._click_action() def update_card( self, @@ -162,16 +177,16 @@ class _RouteDiagram(ctk.CTkFrame): """Responsive diagram with smooth connections behind native widgets.""" def __init__(self, master: Any): - super().__init__(master, height=330, fg_color="transparent") + super().__init__(master, height=440, fg_color="transparent") 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=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.02 + index * 0.29, relwidth=0.278) + slot.place(relx=0.71, rely=0.01 + index * 0.185, relwidth=0.278) 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) @@ -180,16 +195,19 @@ class _RouteDiagram(ctk.CTkFrame): self.context, text="▤ Хранилище контекста", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY ).pack(pady=(6, 0)) self.context_status = ctk.CTkLabel( - self.context, text="● Состояние: Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED + self.context, + text="● Нет телеметрии хранилища", + font=Theme.font_micro(), + text_color=Theme.TEXT_MUTED, ) self.context_status.pack() self._left_labels = ["", "", ""] - self._right_labels = ["", "", ""] + self._right_labels = ["", "", "", "", ""] self.bind("", self._redraw) def set_labels(self, left: list[str], right: list[str]) -> None: self._left_labels = (left + ["", "", ""])[:3] - self._right_labels = (right + ["", "", ""])[:3] + self._right_labels = (right + ["", "", "", "", ""])[:5] self._redraw() def _redraw(self, _event: Any = None) -> None: @@ -197,8 +215,8 @@ 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 - ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)] - for index, y_pos in enumerate(ys): + left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)] + for index, y_pos in enumerate(left_ys): self.canvas.create_line( left_x, y_pos, @@ -223,6 +241,8 @@ class _RouteDiagram(ctk.CTkFrame): anchor="w", tags="route", ) + right_ys = [height * (0.01 + index * 0.185) + 34 for index in range(5)] + for index, y_pos in enumerate(right_ys): self.canvas.create_line( center_x + 48, center_y, @@ -373,13 +393,35 @@ class DashboardView(ctk.CTkFrame): # Kept as a presentation-state probe for tests/accessibility; the same # status is rendered once in the global header, as in the approved mockup. + self.empty_state = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.ACCENT_DIM) + empty_copy = ctk.CTkFrame(self.empty_state, fg_color="transparent") + empty_copy.pack(side="left", fill="x", expand=True, padx=14, pady=10) + ctk.CTkLabel( + empty_copy, + text="Подключите первый аккаунт", + font=Theme.font_heading(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w") + ctk.CTkLabel( + empty_copy, + text="Hermes назначит профиль роли и покажет реальную квоту, модель и цепочку отказоустойчивости.", + font=Theme.font_caption(), + text_color=Theme.TEXT_SECONDARY, + ).pack(anchor="w", pady=(2, 0)) + HubButton( + self.empty_state, + text="Добавить аккаунт", + variant="primary", + command=lambda: self.on_action("add_account", {}) if self.on_action else None, + ).pack(side="right", padx=12, pady=10) + self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent") self.metrics.pack(fill="x", pady=(0, 8)) for column in range(5): 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/0", "готовые роли") + self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы") self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н/Д", "P50") self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н/Д", "failover") for index, metric in enumerate( @@ -389,10 +431,9 @@ class DashboardView(ctk.CTkFrame): body = ctk.CTkFrame(self.scroll, fg_color="transparent") body.pack(fill="x") - body.grid_columnconfigure(0, weight=4) - body.grid_columnconfigure(1, weight=1) - route_card = HubCard(body, height=370) - route_card.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) + body.grid_columnconfigure(0, weight=1) + route_card = HubCard(body, height=480) + route_card.grid(row=0, column=0, sticky="nsew") route_card.grid_propagate(False) ctk.CTkLabel( route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY @@ -400,8 +441,10 @@ class DashboardView(ctk.CTkFrame): self.route_diagram = _RouteDiagram(route_card) self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6)) - self.realtime = HubCard(body, height=370) - self.realtime.grid(row=0, column=1, sticky="nsew") + # 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, @@ -473,7 +516,17 @@ class DashboardView(ctk.CTkFrame): for bucket in quota.buckets: if bucket.remaining_percent is not None: return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent) - return "Н/Д", None + return "Нет данных API", None + + @staticmethod + def _agent_quota_measurement(snapshot: HubSnapshot, agent: Any) -> tuple[str, Optional[float]]: + quota = snapshot.quotas.get(agent.assigned_profile_id) + if quota and not getattr(quota, "is_estimated", True): + bucket = quota.get_bucket_for_model(agent.model) + 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 @staticmethod def _sync_endpoint_cards( @@ -481,7 +534,7 @@ class DashboardView(ctk.CTkFrame): cache: Dict[str, _EndpointCard], items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]], ) -> None: - prepared = list(items)[:3] + prepared = list(items)[: len(slots)] live = {key for key, *_rest in prepared} for key in list(cache): if key not in live: @@ -507,6 +560,10 @@ class DashboardView(ctk.CTkFrame): text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY, ) readiness = snapshot.readiness + if readiness.accounts_connected_count == 0: + self.empty_state.pack(fill="x", pady=(0, 8), before=self.metrics) + else: + self.empty_state.pack_forget() telemetry = dict(snapshot.metrics.get("telemetry") or {}) global_telemetry = dict(telemetry.get("global") or {}) provider_telemetry = dict(telemetry.get("by_provider") or {}) @@ -526,9 +583,9 @@ class DashboardView(ctk.CTkFrame): float(total_calls) if total_calls is not None else None, ) self.agents_metric.set_metric( - f"{readiness.roles_ready_count}/{readiness.total_roles}", - "готовые роли", - float(readiness.roles_ready_count), + str(readiness.accounts_connected_count), + f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}", + float(readiness.accounts_connected_count), ) self.latency_metric.set_metric( f"{latency:.0f} мс" if latency is not None else "Н/Д", @@ -550,13 +607,16 @@ class DashboardView(ctk.CTkFrame): provider.provider_id, provider.provider_id, provider.provider_name, - next( - ( - profile.preferred_models[0] - for profile in snapshot.profiles_by_provider.get(provider.provider_id, []) - if profile.preferred_models - ), - "Модель: Н/Д", + ( + f"{provider.connected_count} аккаунт(а) • " + + next( + ( + profile.preferred_models[0] + for profile in snapshot.profiles_by_provider.get(provider.provider_id, []) + if profile.preferred_models + ), + "модель: Н/Д", + ) ), "Онлайн" if provider.online_count else "Недоступен", *self._quota_measurement(snapshot, provider.provider_id), @@ -575,33 +635,51 @@ class DashboardView(ctk.CTkFrame): else: self.route_diagram.orchestrator.update_node("Не назначен", "Н/Д", False) - agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3] - self._sync_endpoint_cards( - self.route_diagram.agent_slots, - self._agent_cards, - ( + 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) + agent_items.append( ( agent.role_id, agent.provider, agent.role_name_ru, f"{agent.provider_display_name} • {agent.model}", "Здорово" if agent.is_active else agent.status_label_ru, - agent.active_quota_label or "Н/Д", - None, + agent_quota_text, + agent_quota_percent, "healthy" if agent.is_active else "warning", ) - for agent in agents - ), + ) + self._sync_endpoint_cards( + self.route_diagram.agent_slots, + self._agent_cards, + agent_items, ) + for agent in agents: + card = self._agent_cards.get(agent.role_id) + if card is not None: + card.set_click_action( + lambda current=agent: self.on_action( + "agent_settings", + { + "role_id": current.role_id, + "profile_id": current.assigned_profile_id, + "provider": current.provider, + }, + ) + if self.on_action + else None + ) 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} @@ -615,11 +693,9 @@ class DashboardView(ctk.CTkFrame): 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_latency:.0f} мс" - if provider_latency is not None - else f"{provider.online_count}/{provider.connected_count}" - ) + 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 {}) diff --git a/src/antigravity_provider/router/ui/views/routing_view.py b/src/antigravity_provider/router/ui/views/routing_view.py index e16e9b1..83dcef3 100644 --- a/src/antigravity_provider/router/ui/views/routing_view.py +++ b/src/antigravity_provider/router/ui/views/routing_view.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Callable, Dict, Optional +import tkinter as tk import customtkinter as ctk @@ -26,7 +27,7 @@ class RoutingRoleWidget(HubCard): self.meta.pack(side="left", padx=Theme.SPACE_SM) ActionButton( top, - text="Настроить", + text="Изменить цепочку →", variant="secondary", width=90, command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}), @@ -90,6 +91,19 @@ class RoutingView(ctk.CTkFrame): self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) + def focus_role(self, role_id: str) -> None: + """Focus the existing route editor/card selected in the graph inspector.""" + widget = self._role_widgets.get(role_id) + if not widget: + return + for current in self._role_widgets.values(): + current.configure(border_color=Theme.BORDER) + widget.configure(border_color=Theme.BORDER_ACCENT) + try: + self.scroll._parent_canvas.yview_moveto(max(0.0, widget.winfo_y() / max(1, self.scroll.winfo_height()))) + except (AttributeError, tk.TclError): + pass + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: if not isinstance(snapshot, HubSnapshot): return diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py index 1cecf60..92129f3 100644 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ b/src/antigravity_provider/router/ui/views/team_view.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Callable, Dict, List, Optional +import tkinter as tk import customtkinter as ctk from antigravity_provider.router.ui.theme import Theme @@ -24,6 +25,49 @@ from antigravity_provider.router.unified_health import ( STATUS_AUTH_EXPIRED, ) from antigravity_provider.router.state_store import HubSnapshot +from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.event_bus import ( + EVENT_ACCOUNT_ADDED, + EVENT_ACCOUNT_AUTH_CHANGED, + EVENT_ACCOUNT_REMOVED, + EVENT_ACCOUNT_UPDATED, + EVENT_QUOTA_UPDATED, + EVENT_ROUTING_UPDATED, + EventBus, +) +from antigravity_provider.router.router_config import load_router_config +from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController + + +def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]: + """Persist one ordered chain using AutoAssigner while preserving other roles.""" + config = load_router_config() + policy = config.roles.get(role_id) + if policy is None: + return False, f"Роль '{role_id}' не найдена" + if len(desired_chain) != len(set(desired_chain)): + return False, "Профиль не может повторяться в одной цепочке" + missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles] + if missing: + return False, f"Профиль '{missing[0]}' не найден" + + original = {key: list(value.preferred_chain) for key, value in config.roles.items()} + removed = set(original[role_id]) - set(desired_chain) + affected = {role_id} + for profile_id in removed: + affected.update(key for key, chain in original.items() if profile_id in chain) + ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False) + if not ok: + return False, message + + chains = {key: original[key] for key in affected} + chains[role_id] = list(desired_chain) + for target_role, chain in chains.items(): + for profile_id in reversed(chain): + ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True) + if not ok: + return False, message + return True, f"Цепочка '{role_id}' сохранена" class AgentCardWidget(HubCard): @@ -116,6 +160,27 @@ class AgentCardWidget(HubCard): command=self._open_menu, ) self.menu_btn.pack(side="right") + self._bind_settings_click(self) + + def _bind_settings_click(self, widget: Any) -> None: + if isinstance(widget, ctk.CTkButton): + return + widget.bind("", self._open_settings, add="+") + for child in widget.winfo_children(): + self._bind_settings_click(child) + + def _open_settings(self, _event: Any = None) -> None: + if not self.agent_data or not self.on_action: + return + agent = self.agent_data + self.on_action( + "agent_settings", + { + "role_id": agent.role_id, + "profile_id": agent.assigned_profile_id or "", + "provider": agent.provider, + }, + ) def update_agent(self, a: AgentViewModel): self.agent_data = a @@ -247,153 +312,638 @@ class AgentCardWidget(HubCard): class TeamView(ctk.CTkFrame): + """Interactive canvas over the existing router role/profile chains.""" + + NODE_W = 218 + NODE_H = 104 + 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.controller = RoutingGraphController() + self.snapshot: Optional[HubSnapshot] = None + self._live_pipelines: Dict[str, Any] = {} + self._quota_overrides: Dict[str, Any] = {} + self.selected_role = "orchestrator" + self.selected_edge = "" + self._drag_role = "" + self._drag_origin = (0.0, 0.0) + self._drag_node_origin = (0.0, 0.0) + self._node_items: Dict[str, tuple[int, ...]] = {} + self._edge_items: Dict[str, tuple[int, ...]] = {} + self._event_bus = EventBus.get() + self._subscribed = False self._build_static_layout() - self.update_data() + self._subscribe_runtime_events() + self._draw_graph(rebuild=True) + self.after(20, self._restore_viewport) - 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)) + def destroy(self): + if self._subscribed: + for name in self._runtime_events(): + self._event_bus.unsubscribe(name, self._on_runtime_event) + self._subscribed = False + super().destroy() - left_titles = ctk.CTkFrame(header_frame, fg_color="transparent") - left_titles.pack(side="left") + @staticmethod + def _runtime_events() -> tuple[str, ...]: + return ( + EVENT_ROUTING_UPDATED, + EVENT_QUOTA_UPDATED, + EVENT_ACCOUNT_ADDED, + EVENT_ACCOUNT_UPDATED, + EVENT_ACCOUNT_REMOVED, + EVENT_ACCOUNT_AUTH_CHANGED, + ) - ctk.CTkLabel( - left_titles, - text="Команда агентов", - font=Theme.font_title_page(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w") + def _subscribe_runtime_events(self) -> None: + if self._subscribed: + return + for name in self._runtime_events(): + self._event_bus.subscribe(name, self._on_runtime_event) + self._subscribed = True - ctk.CTkLabel( - left_titles, - text="Управляйте командой Hermes и их ролями", - font=Theme.font_caption(), - text_color=Theme.TEXT_MUTED, - ).pack(anchor="w", pady=(2, 0)) + def _build_static_layout(self) -> None: + header = ctk.CTkFrame(self, fg_color="transparent") + header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(12, 8)) + titles = ctk.CTkFrame(header, fg_color="transparent") + titles.pack(side="left") + ctk.CTkLabel(titles, text="Граф маршрутизации", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY).pack( + anchor="w" + ) + self.state_label = ctk.CTkLabel( + titles, text="Роли и реальные failover-цепочки", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED + ) + self.state_label.pack(anchor="w") + actions = ctk.CTkFrame(header, fg_color="transparent") + actions.pack(side="right") + for text, command in ( + ("↶", self._undo), + ("↷", self._redo), + ("Авто", self._auto_layout), + ("Вписать", self.fit_to_screen), + ("Сохранить", self._save), + ): + HubButton(actions, text=text, variant="primary" if text == "Сохранить" else "secondary", command=command).pack( + side="left", padx=3 + ) - right_actions = ctk.CTkFrame(header_frame, fg_color="transparent") - right_actions.pack(side="right") + toolbar = ctk.CTkFrame(self, fg_color=Theme.SURFACE, corner_radius=Theme.RADIUS_SM) + toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, 7)) + self.search = ctk.CTkEntry(toolbar, placeholder_text="Найти роль или профиль…", width=250) + self.search.pack(side="left", padx=8, pady=6) + self.search.bind("", self._search) + ctk.CTkLabel(toolbar, text="Связь", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack( + side="left", padx=(8, 4) + ) + self.edge_type = ctk.CTkOptionMenu(toolbar, values=list(EDGE_TYPES), width=112) + self.edge_type.set("DELEGATE") + self.edge_type.pack(side="left", pady=6) + roles = [node.role_id for node in self.controller.graph.nodes] or ["orchestrator"] + self.edge_target = ctk.CTkOptionMenu(toolbar, values=roles, width=135) + self.edge_target.set(next((role for role in roles if role != self.selected_role), roles[0])) + self.edge_target.pack(side="left", padx=(5, 0), pady=6) + profile_ids = list(load_router_config().profiles) or ["—"] + self.edge_profile = ctk.CTkOptionMenu(toolbar, values=["—", *profile_ids], width=140) + self.edge_profile.set("—") + self.edge_profile.pack(side="left", padx=(5, 0), pady=6) + HubButton(toolbar, text="Соединить", variant="secondary", command=self._connect_selected).pack( + side="left", padx=5 + ) + HubButton(toolbar, text="Изменить", variant="secondary", command=self._change_selected_edge).pack( + side="left", padx=(0, 5) + ) + self.zoom_label = ctk.CTkLabel(toolbar, text="100%", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) + self.zoom_label.pack(side="right", padx=10) + body = ctk.CTkFrame(self, fg_color="transparent") + body.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) + body.grid_rowconfigure(0, weight=1) + body.grid_columnconfigure(0, weight=4) + body.grid_columnconfigure(1, weight=1) + canvas_frame = ctk.CTkFrame(body, fg_color=Theme.SURFACE_MUTED, border_width=1, border_color=Theme.BORDER) + canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) + self.canvas = tk.Canvas( + canvas_frame, + bg=Theme.SURFACE_MUTED, + highlightthickness=0, + xscrollincrement=1, + yscrollincrement=1, + scrollregion=(0, 0, 1800, 1200), + ) + self.canvas.pack(fill="both", expand=True) + self.canvas.bind("", self._on_press) + self.canvas.bind("", self._on_drag) + self.canvas.bind("", self._on_release) + self.canvas.bind("", self._on_zoom) + self.canvas.bind("", self._start_pan) + self.canvas.bind("", self._pan) + self.canvas.bind("", self._end_pan) + self.canvas.bind("", lambda _e: self._undo()) + self.canvas.bind("", lambda _e: self._redo()) + self.canvas.bind("", self._delete_selected_edge) + self.canvas.focus_set() + + self.minimap = tk.Canvas(canvas_frame, width=155, height=95, bg=Theme.SURFACE, highlightthickness=1) + self.minimap.place(relx=1.0, rely=1.0, x=-12, y=-12, anchor="se") + + self.inspector = ctk.CTkFrame(body, fg_color=Theme.SURFACE, border_width=1, border_color=Theme.BORDER) + self.inspector.grid(row=0, column=1, sticky="nsew") + self.inspector_title = ctk.CTkLabel( + self.inspector, text="Инспектор роли", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY + ) + self.inspector_title.pack(anchor="w", padx=12, pady=(12, 2)) + self.inspector_status = ctk.CTkLabel( + self.inspector, text="", justify="left", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED + ) + self.inspector_status.pack(fill="x", padx=12, pady=(0, 8)) + self.chain_frame = ctk.CTkScrollableFrame(self.inspector, fg_color="transparent") + self.chain_frame.pack(fill="both", expand=True, padx=8) HubButton( - right_actions, - text="+ Добавить агент", + self.inspector, + text="Открыть маршрутизацию", variant="primary", - height=Theme.HEIGHT_BTN_MD, - command=lambda: self._trigger_action("add_account", {}), - ).pack(side="left", padx=(0, 8)) + command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}), + ).pack(fill="x", padx=10, pady=10) - ctk.CTkButton( - right_actions, - text="⋮", - width=38, - height=Theme.HEIGHT_BTN_MD, - fg_color=Theme.SURFACE, - hover_color=Theme.SURFACE_HOVER, + def _world(self, x: float, y: float) -> tuple[float, float]: + zoom = self.controller.graph.zoom + return self.canvas.canvasx(x) / zoom, self.canvas.canvasy(y) / zoom + + def _on_press(self, event: Any) -> None: + item = self.canvas.find_closest(self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)) + tags = self.canvas.gettags(item) + edge = next((tag[5:] for tag in tags if tag.startswith("edge:")), "") + if edge: + self.selected_edge = edge + selected = next((item for item in self.controller.graph.edges if item.edge_id == edge), None) + if selected: + self.edge_type.set(selected.edge_type) + self.edge_target.set(selected.target) + self.edge_profile.set(selected.profile_id or "—") + self._draw_graph(rebuild=True) + return + role = next((tag[5:] for tag in tags if tag.startswith("role:")), "") + if role: + self.selected_role = role + self.selected_edge = "" + self._drag_role = role + 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) + self._update_inspector() + self._update_live_styles() + + def _on_drag(self, event: Any) -> None: + if not self._drag_role: + return + node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None) + if not node: + return + x, y = self._world(event.x, event.y) + dx, dy = x - self._drag_origin[0], y - self._drag_origin[1] + self._drag_origin = (x, y) + node.x += dx + node.y += dy + self.controller.dirty = True + self._draw_graph(rebuild=True) + + def _on_release(self, _event: Any) -> None: + if 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 + node.x, node.y = self._drag_node_origin + self.controller.move_node(node.role_id, final_x, final_y) + self._drag_role = "" + self._set_dirty_text() + + def _on_zoom(self, event: Any) -> str: + factor = 1.1 if event.delta > 0 else 0.9 + self.controller.graph.zoom = max(0.45, min(1.8, self.controller.graph.zoom * factor)) + self.controller.dirty = True + self._draw_graph(rebuild=True) + self._set_dirty_text() + return "break" + + def _start_pan(self, event: Any) -> None: + self.canvas.scan_mark(event.x, event.y) + + def _pan(self, event: Any) -> None: + self.canvas.scan_dragto(event.x, event.y, gain=1) + + def _end_pan(self, _event: Any) -> None: + zoom = self.controller.graph.zoom + self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom + self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom + self.controller.dirty = True + self._set_dirty_text() + + def _restore_viewport(self) -> None: + graph = self.controller.graph + z = graph.zoom + self.canvas.xview_moveto(max(0.0, graph.viewport_x * z / 1800.0)) + self.canvas.yview_moveto(max(0.0, graph.viewport_y * z / 1200.0)) + + def _node_coords(self, role_id: str) -> tuple[float, float, float, float]: + node = next(item for item in self.controller.graph.nodes if item.role_id == role_id) + z = self.controller.graph.zoom + return node.x * z, node.y * z, (node.x + self.NODE_W) * z, (node.y + self.NODE_H) * z + + def _draw_graph(self, rebuild: bool = True) -> None: + if rebuild: + self.canvas.delete("all") + self._node_items.clear() + self._edge_items.clear() + z = self.controller.graph.zoom + for edge in self.controller.graph.edges: + try: + sx1, sy1, sx2, sy2 = self._node_coords(edge.source) + tx1, ty1, _tx2, ty2 = self._node_coords(edge.target) + except StopIteration: + continue + start = (sx2, (sy1 + sy2) / 2) + end = (tx1, (ty1 + ty2) / 2) + dash = () if edge.edge_type == "PRIMARY" else (7, 4) if edge.edge_type == "FALLBACK" else (2, 4) + width = 3 if edge.edge_type == "PRIMARY" else 2 + line = self.canvas.create_line( + *start, + *end, + smooth=True, + arrow="last", + width=width, + dash=dash, + fill=Theme.ACCENT if edge.edge_id == self.selected_edge else Theme.TEXT_ACCENT, + tags=(f"edge:{edge.edge_id}", "edge"), + ) + label = self.canvas.create_text( + (start[0] + end[0]) / 2, + (start[1] + end[1]) / 2 - 9, + text=edge.edge_type, + fill=Theme.TEXT_MUTED, + font=("Segoe UI", max(7, int(8 * z)), "bold"), + tags=(f"edge:{edge.edge_id}", "edge"), + ) + self._edge_items[edge.edge_id] = (line, label) + config = load_router_config() + for node in self.controller.graph.nodes: + x1, y1, x2, y2 = self._node_coords(node.role_id) + pipeline = self._pipeline_for(node.role_id) + active = pipeline.active_profile_id if pipeline else "" + chain = list(config.roles.get(node.role_id).preferred_chain) if node.role_id in config.roles else [] + selected = node.role_id == self.selected_role + rect = self.canvas.create_rectangle( + x1, + y1, + x2, + y2, + width=2 if selected else 1, + outline=Theme.ACCENT if selected else Theme.BORDER, + fill=Theme.SURFACE, + tags=(f"role:{node.role_id}", "node"), + ) + title = self.canvas.create_text( + x1 + 12 * z, + y1 + 17 * z, + anchor="w", + text=node.label or node.role_id, + fill=Theme.TEXT_PRIMARY, + font=("Segoe UI", max(9, int(11 * z)), "bold"), + tags=(f"role:{node.role_id}", "node"), + ) + active_text = f"Активен: {active}" if active else "Активный профиль: Н/Д" + meta = self.canvas.create_text( + x1 + 12 * z, + y1 + 43 * z, + anchor="w", + text=active_text, + fill=Theme.STATUS_HEALTHY if active else Theme.TEXT_MUTED, + font=("Segoe UI", max(7, int(8 * z))), + tags=(f"role:{node.role_id}", "node"), + ) + chain_text = " → ".join(chain[:3]) if chain else "Нет профилей" + chain_item = self.canvas.create_text( + x1 + 12 * z, + y1 + 68 * z, + anchor="w", + width=(self.NODE_W - 24) * z, + text=chain_text, + fill=Theme.TEXT_SECONDARY, + font=("Segoe UI", max(7, int(8 * z))), + tags=(f"role:{node.role_id}", "node"), + ) + self._node_items[node.role_id] = (rect, title, meta, chain_item) + self.zoom_label.configure(text=f"{self.controller.graph.zoom * 100:.0f}%") + bounds = self.canvas.bbox("all") + if bounds: + self.canvas.configure(scrollregion=(0, 0, max(1800, bounds[2] + 120), max(1200, bounds[3] + 120))) + self._draw_minimap() + self._update_inspector() + + def _draw_minimap(self) -> None: + self.minimap.delete("all") + if not self.controller.graph.nodes: + return + max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W + max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H + scale = min(145 / max(max_x, 1), 85 / max(max_y, 1)) + for node in self.controller.graph.nodes: + self.minimap.create_rectangle( + 5 + node.x * scale, + 5 + node.y * scale, + 5 + (node.x + self.NODE_W) * scale, + 5 + (node.y + self.NODE_H) * scale, + outline=Theme.ACCENT if node.role_id == self.selected_role else Theme.BORDER, + fill=Theme.SURFACE_MUTED, + ) + + def _update_inspector(self) -> None: + for child in self.chain_frame.winfo_children(): + child.destroy() + role = self.selected_role + pipeline = self._pipeline_for(role) + 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 'Н/Д'}" + ) + config = load_router_config() + policy = config.roles.get(role) + chain = list(policy.preferred_chain) if policy else [] + live_nodes = {item.profile_id: item for item in pipeline.nodes} if pipeline else {} + for index, profile_id in enumerate(chain): + profile = config.profiles.get(profile_id) + live = live_nodes.get(profile_id) + card = HubCard(self.chain_frame) + card.pack(fill="x", pady=4) + rank = "PRIMARY" if index == 0 else f"FALLBACK {index}" + ctk.CTkLabel(card, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack( + anchor="w", padx=8, pady=(6, 0) + ) + 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 "Н/Д") + identity = live.account_identity if live and live.account_identity 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 "Н/Д" + ctk.CTkLabel( + card, + text=f"{provider} • {model}\n{identity}\nКвота: {quota}\nFailover: {reason}", + justify="left", + anchor="w", + font=Theme.font_micro(), + text_color=Theme.TEXT_SECONDARY, + ).pack(fill="x", padx=8, pady=(2, 7)) + controls = ctk.CTkFrame(card, fg_color="transparent") + controls.pack(fill="x", padx=6, pady=(0, 6)) + HubButton( + controls, + text="↑", + variant="secondary", + width=34, + height=26, + command=lambda pid=profile_id: self._move_chain_profile(pid, -1), + ).pack(side="left", padx=2) + HubButton( + controls, + text="↓", + variant="secondary", + width=34, + height=26, + command=lambda pid=profile_id: self._move_chain_profile(pid, 1), + ).pack(side="left", padx=2) + HubButton( + controls, + text="Удалить", + variant="ghost", + width=76, + height=26, + command=lambda pid=profile_id: self._remove_chain_profile(pid), + ).pack(side="right", padx=2) + if not chain: + ctk.CTkLabel( + self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING + ).pack(anchor="w", padx=6, pady=8) + + available = [profile_id for profile_id in config.profiles if profile_id not in chain] + add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent") + add_row.pack(fill="x", pady=(8, 2)) + add_menu = ctk.CTkOptionMenu( + add_row, + values=available or ["—"], + font=Theme.font_caption(), + fg_color=Theme.SURFACE_MUTED, + button_color=Theme.SECONDARY, + button_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") + add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4)) + HubButton( + add_row, + text="Добавить", + variant="secondary", + width=88, + command=lambda: self._add_chain_profile(add_menu.get()), + state="normal" if available else "disabled", + ).pack(side="right") - self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼") - self.m2.grid(row=0, column=1, padx=6, sticky="nsew") + def _persist_selected_chain(self, chain: List[str]) -> None: + ok, message = persist_role_chain(self.selected_role, chain) + self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR) + if ok: + self._update_inspector() - self.m3 = HubMetricCard(metrics_grid, title="ПРОВАЙДЕРЫ", value="3/3", subtext="доступно", icon="⚛") - self.m3.grid(row=0, column=2, padx=6, sticky="nsew") + def _move_chain_profile(self, profile_id: str, direction: int) -> None: + chain = list(self.controller.role_chain(self.selected_role)) + if profile_id not in chain: + return + source = chain.index(profile_id) + target = source + direction + if target < 0 or target >= len(chain): + self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING) + return + chain[source], chain[target] = chain[target], chain[source] + self._persist_selected_chain(chain) - self.m4 = HubMetricCard( - metrics_grid, title="СОСТОЯНИЕ", value="Healthy", subtext="Все системы работают", icon="🛡️" - ) - self.m4.grid(row=0, column=3, padx=6, sticky="nsew") + def _remove_chain_profile(self, profile_id: str) -> None: + chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id] + self._persist_selected_chain(chain) - # ── 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) + def _add_chain_profile(self, profile_id: str) -> None: + if not profile_id or profile_id == "—": + return + chain = list(self.controller.role_chain(self.selected_role)) + if profile_id not in chain: + chain.append(profile_id) + self._persist_selected_chain(chain) - 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 _on_runtime_event(self, name: str, data: Any) -> None: + # EventBus may publish from a worker. Tk mutation is always marshalled. + try: + self.after(0, lambda: self._apply_runtime_event(name, data)) + except Exception: + pass - def update_data(self, snapshot: Optional[Any] = None): + def _apply_runtime_event(self, name: str, data: Any) -> None: + payload = data if isinstance(data, dict) else {} + if name == EVENT_ROUTING_UPDATED and payload.get("pipeline") is not None: + self._live_pipelines[str(payload.get("role_id", ""))] = payload["pipeline"] + elif name == EVENT_QUOTA_UPDATED and payload.get("profile_id"): + self._quota_overrides[str(payload["profile_id"])] = payload.get("quota_snapshot") or payload.get("snapshot") + self._update_live_styles() + + def _pipeline_for(self, role_id: str) -> Any: + if role_id in self._live_pipelines: + return self._live_pipelines[role_id] + return self.snapshot.routing.get(role_id) if self.snapshot else None + + def _update_live_styles(self) -> None: + """Update existing canvas items; runtime events never rebuild the canvas.""" + if not self.snapshot: + return + for role_id, items in self._node_items.items(): + pipeline = self._pipeline_for(role_id) + active = pipeline.active_profile_id if pipeline else "" + self.canvas.itemconfigure(items[0], outline=Theme.ACCENT if role_id == self.selected_role else Theme.BORDER) + self.canvas.itemconfigure(items[2], text=f"Активен: {active}" if active else "Активный профиль: Н/Д") + self._update_inspector() + + def update_data(self, snapshot: Optional[Any] = None) -> None: if not isinstance(snapshot, HubSnapshot): return + self.snapshot = snapshot + self._live_pipelines = { + role_id: pipeline + for role_id, pipeline in self._live_pipelines.items() + if role_id in snapshot.routing and pipeline is not snapshot.routing[role_id] + } + if set(snapshot.routing) != set(self._node_items): + self._draw_graph(rebuild=True) + else: + self._update_live_styles() - readiness = snapshot.readiness - agents = snapshot.agents + def _search(self, _event: Any = None) -> str: + query = self.search.get().strip().lower() + config = load_router_config() + for node in self.controller.graph.nodes: + chain = config.roles.get(node.role_id).preferred_chain if node.role_id in config.roles else [] + if query in node.role_id.lower() or query in node.label.lower() or any(query in item.lower() for item in chain): + self.selected_role = node.role_id + self._draw_graph(rebuild=True) + break + return "break" - # Update metric cards - self.m1.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}") - self.m1.sub_label.configure(text="ролей готовы") + def focus_role(self, role_id: str) -> None: + """Select a role when Routing delegates editing to this single editor.""" + if not any(node.role_id == role_id for node in self.controller.graph.nodes): + self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING) + return + self.selected_role = role_id + self.selected_edge = "" + target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id) + self.edge_target.set(target) + self._draw_graph(rebuild=True) + self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT) - self.m2.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}") - self.m2.sub_label.configure(text="подключено") + def _connect_selected(self) -> None: + if len(self.controller.graph.nodes) < 2: + return + source = self.selected_role + target = self.edge_target.get() + profile_id = self.edge_profile.get() + profile_id = "" if profile_id == "—" else profile_id + ok, message = self.controller.add_edge(source, target, self.edge_type.get(), profile_id) + self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR) + self._draw_graph(rebuild=True) + self._set_dirty_text() - self.m3.val_label.configure(text=f"{readiness.providers_ready_count}/{readiness.total_providers}") - self.m3.sub_label.configure(text="доступно") + def _change_selected_edge(self) -> None: + if not self.selected_edge: + self.state_label.configure(text="Сначала выберите связь на графе", text_color=Theme.STATUS_WARNING) + return + profile_id = self.edge_profile.get() + ok, message = self.controller.set_edge_type( + self.selected_edge, + self.edge_type.get(), + "" if profile_id == "—" else profile_id, + ) + self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR) + self._draw_graph(rebuild=True) + self._set_dirty_text() - self.m4.val_label.configure(text=readiness.title_ru) - self.m4.sub_label.configure(text=readiness.summary_ru) + def _delete_selected_edge(self, _event: Any = None) -> str: + selected = self.canvas.find_withtag("current") + edge_id = self.selected_edge + if selected: + edge_id = next((tag[5:] for tag in self.canvas.gettags(selected[0]) if tag.startswith("edge:")), "") + if edge_id: + self.controller.delete_edge(edge_id) + self.selected_edge = "" + self._draw_graph(rebuild=True) + self._set_dirty_text() + return "break" - 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() + def _auto_layout(self) -> None: + self.controller.auto_layout() + self._draw_graph(rebuild=True) + self._set_dirty_text() - 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") + def _undo(self) -> None: + if self.controller.undo(): + self._draw_graph(rebuild=True) + self._set_dirty_text() - 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 _redo(self) -> None: + if self.controller.redo(): + self._draw_graph(rebuild=True) + self._set_dirty_text() - def _trigger_action(self, action: str, profile: Dict[str, Any]): + def fit_to_screen(self) -> None: + if not self.controller.graph.nodes: + return + self.update_idletasks() + max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W + max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H + self.controller.graph.zoom = max(0.45, min(1.4, min(self.canvas.winfo_width() / max_x, self.canvas.winfo_height() / max_y) * 0.9)) + self.controller.dirty = True + self._draw_graph(rebuild=True) + self._set_dirty_text() + + def _save(self) -> None: + zoom = self.controller.graph.zoom + self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom + self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom + issues = self.controller.save() + if issues: + self._show_issues(issues) + return + self.state_label.configure(text="Сохранено • позиции и масштаб переживут перезапуск", text_color=Theme.STATUS_HEALTHY) + + def _show_issues(self, issues: List[GraphIssue]) -> None: + bad_nodes = {issue.node_id for issue in issues if issue.node_id} + for role_id, items in self._node_items.items(): + self.canvas.itemconfigure(items[0], outline=Theme.STATUS_ERROR if role_id in bad_nodes else Theme.BORDER) + message = " • ".join(issue.message for issue in issues[:3]) + if len(issues) > 3: + message += f" • ещё {len(issues) - 3}" + self.state_label.configure(text=message, text_color=Theme.STATUS_ERROR) + + def _set_dirty_text(self) -> None: + self.state_label.configure( + text="● Есть несохранённые изменения" if self.controller.dirty else "Роли и реальные failover-цепочки", + text_color=Theme.STATUS_WARNING if self.controller.dirty else Theme.TEXT_MUTED, + ) + + def _trigger_action(self, action: str, profile: Dict[str, Any]) -> None: if self.on_action: self.on_action(action, profile) diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 2fb1775..2f25101 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -511,10 +511,17 @@ class UnifiedHealthService: dead_roles = 0 warnings: List[str] = [] - total_accounts = sum(len(profs) for profs in profiles_by_prov.values()) - connected_accounts = sum( - 1 for profs in profiles_by_prov.values() for p in profs if p.auth_state == "AUTHENTICATED" - ) + # Empty/cold placeholders are capacity for future accounts, not broken + # accounts. They remain visible in provider slot counts but must not + # downgrade a system whose configured routes are all operational. + configured_profiles = [ + profile + for profiles in profiles_by_prov.values() + for profile in profiles + if not profile.is_empty_slot + ] + total_accounts = len(configured_profiles) + connected_accounts = sum(1 for profile in configured_profiles if profile.auth_state == "AUTHENTICATED") providers_online = sum( 1 for profs in profiles_by_prov.values() if any(p.health_state == STATUS_HEALTHY for p in profs) diff --git a/tests/test_accounts_tariffs_quotas.py b/tests/test_accounts_tariffs_quotas.py index 31313dc..4d7356b 100644 --- a/tests/test_accounts_tariffs_quotas.py +++ b/tests/test_accounts_tariffs_quotas.py @@ -2,9 +2,11 @@ from __future__ import annotations import json +import io import os import tempfile import time +import urllib.error from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import MagicMock, patch @@ -187,13 +189,30 @@ def test_quota_snapshot_model_availability(): def test_antigravity_separate_claude_and_gemini_buckets(): service = AccountQuotaService() - snap = service._collect_antigravity_quota("ag-w1", {"tokens": {}}) + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + { + "models": { + "claude-sonnet-4-6": { + "quotaInfo": {"remainingFraction": 0.42, "resetTime": "2026-08-23T00:00:00Z"} + }, + "gemini-3.7-flash": { + "quotaInfo": {"remainingFraction": 0.87, "resetTime": "2026-08-23T00:00:00Z"} + }, + } + } + ).encode() + with patch("antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"), patch( + "antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response + ): + snap = service._collect_antigravity_quota( + "ag-w1", {"tokens": {"access_token": "access-token"}} + ) bucket_ids = [b.id for b in snap.buckets] - assert "antigravity.claude.5h" in bucket_ids - assert "antigravity.claude.weekly" in bucket_ids - assert "antigravity.gemini.5h" in bucket_ids - assert "antigravity.gemini.weekly" in bucket_ids + assert "antigravity.claude.model_pool" in bucket_ids + assert "antigravity.gemini.model_pool" in bucket_ids + assert snap.source == "provider_api" # Claude bucket and Gemini bucket are independent b_c = snap.get_bucket_for_model("claude-3-7-sonnet") @@ -201,6 +220,140 @@ def test_antigravity_separate_claude_and_gemini_buckets(): assert b_c is not None and b_c.model_family == "claude" assert b_g is not None and b_g.model_family == "gemini" + assert b_c.remaining_percent == 42.0 + assert b_g.remaining_percent == 87.0 + + +def test_antigravity_grouped_summary_includes_five_hour_and_weekly_buckets(): + service = AccountQuotaService() + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-weekly", + "window": "weekly", + "remainingFraction": 0.73, + "resetTime": "2026-08-27T00:00:00Z", + }, + { + "bucketId": "gemini-5h", + "window": "5h", + "remainingFraction": 0.91, + "resetTime": "2026-08-23T05:00:00Z", + }, + ], + }, + { + "displayName": "Claude and GPT models", + "buckets": [ + { + "bucketId": "3p-weekly", + "window": "weekly", + "remainingFraction": 0.44, + "resetTime": "2026-08-27T00:00:00Z", + }, + { + "bucketId": "3p-5h", + "window": "5h", + "remainingFraction": 0.82, + "resetTime": "2026-08-23T05:00:00Z", + }, + ], + }, + ] + } + ).encode() + + with patch( + "antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response + ) as urlopen: + snapshot = service._collect_antigravity_quota( + "ag-w1", {"token": {"access_token": "access-token"}, "project_id": "project-1"} + ) + + assert urlopen.call_count == 1 + assert [bucket.id for bucket in snapshot.buckets] == [ + "antigravity.claude.5h", + "antigravity.gemini.5h", + "antigravity.claude.7d", + "antigravity.gemini.7d", + ] + assert [bucket.remaining_percent for bucket in snapshot.buckets] == [82.0, 91.0, 44.0, 73.0] + assert snapshot.get_bucket_for_model("gemini-3.1-pro").remaining_percent == 73.0 + assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 44.0 + + +def test_antigravity_refreshes_expired_token_before_project_discovery(): + service = AccountQuotaService() + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + { + "models": { + "claude-sonnet-4-6": { + "quotaInfo": {"remainingFraction": 0.64, "resetTime": "2026-08-23T00:00:00Z"} + }, + "gemini-3.7-flash": { + "quotaInfo": {"remainingFraction": 0.91, "resetTime": "2026-08-23T00:00:00Z"} + }, + } + } + ).encode() + auth_data = { + "token": { + "access_token": "expired-token", + "refresh_token": "refresh-token", + "expires_at": "2026-08-22T00:00:00Z", + } + } + + with patch( + "antigravity_provider.oauth.refresh_access_token", + return_value={"access_token": "fresh-token", "expires_at": "2026-08-23T00:00:00Z"}, + ) as refresh, patch( + "antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1" + ) as discover, patch( + "antigravity_provider.router.quota_collector.ProfileAuthManager.save_profile_auth" + ) as save, patch( + "antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response + ) as urlopen: + snapshot = service._collect_antigravity_quota("ag-w1", auth_data) + + refresh.assert_called_once_with("refresh-token") + discover.assert_called_once_with("fresh-token") + assert "Bearer fresh-token" == urlopen.call_args.args[0].headers["Authorization"] + assert save.called + assert auth_data["project_id"] == "project-1" + assert snapshot.source == "provider_api" + assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 64.0 + + +def test_opencode_shows_published_limits_and_subscription_error(): + service = AccountQuotaService() + models_response = MagicMock() + models_response.__enter__.return_value.read.return_value = b'{"data": []}' + entitlement_error = urllib.error.HTTPError( + "https://opencode.ai/zen/go/v1/usage", + 403, + "Forbidden", + {}, + io.BytesIO(b'{"message":"OpenCode Go subscription required"}'), + ) + + with patch( + "antigravity_provider.router.quota_collector.urllib.request.urlopen", + side_effect=[models_response, entitlement_error], + ): + snapshot = service._collect_opencode_quota("opengo-1", {"api_key": "test-key"}) + + assert snapshot.source == "provider_api" + assert snapshot.unavailable_reason == "Для этого ключа не активна подписка OpenCode Go" + assert [bucket.limit_absolute for bucket in snapshot.buckets] == [12, 30, 60] + assert [bucket.period for bucket in snapshot.buckets] == ["5h", "7d", "30d"] + assert all(bucket.remaining_percent is None for bucket in snapshot.buckets) def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path): @@ -216,6 +369,17 @@ def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_ # Gemini should remain healthy! assert tracker.is_healthy("ag-w1", "gemini-2.5-pro") is True + + +def test_live_measured_quota_clears_stale_exhaustion(tmp_path): + tracker = HealthTracker(state_file=tmp_path / "router_state.json") + tracker.mark_quota_exhausted("ag-w1", "gemini-3.1-pro", duration=3600, reason="old 429") + assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is False + + changed = tracker.reconcile_measured_quota("ag-w1", {"gemini": 73.0, "claude": 44.0}) + + assert changed is True + assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is True assert tracker.is_healthy("ag-w1", "gemini-2.5-flash") is True diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py index 3a93fd2..8cb88b3 100644 --- a/tests/test_ui_contract_v11.py +++ b/tests/test_ui_contract_v11.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace import time import pytest @@ -21,6 +22,7 @@ from antigravity_provider.router.unified_health import ( AgentViewModel, PipelineNode, ProfileViewModel, + ProviderSummary, RolePipeline, SystemReadiness, ) @@ -95,6 +97,29 @@ def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None: card.destroy() +@pytest.mark.ui +def test_live_quota_overrides_stale_exhausted_card_status(ui_root) -> None: + card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity") + profile = replace(_profile(), health_state="quota_exhausted", health_label_ru="Квота исчерпана") + snapshot = QuotaSnapshot( + account_id="account-1", + provider="antigravity", + source="provider_api", + buckets=[ + QuotaBucket(id="claude", display_name="Claude", remaining_percent=100.0), + QuotaBucket(id="gemini", display_name="Gemini", remaining_percent=100.0), + ], + ) + try: + card.pack() + card.update_account(profile, snapshot) + ui_root.update_idletasks() + assert card.status.label.cget("text") == "Работает" + assert card.status.dot.cget("text_color") == Theme.STATUS_HEALTHY + finally: + card.destroy() + + @pytest.mark.ui def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None: widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h") @@ -187,6 +212,58 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N team_card.destroy() +def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None: + agent = AgentViewModel( + role_id="coder-primary", + role_name_ru="Кодер 1", + role_description_ru="Основной кодер", + assigned_profile_id="ag-w1", + assigned_display_name="Primary", + provider="antigravity", + provider_display_name="Google Antigravity", + model="gemini-3.1-pro", + account_identity="user@example.test", + routing_position="Primary", + status="healthy", + status_label_ru="Работает", + is_active=True, + is_main_orchestrator=False, + active_quota_status="healthy", + active_quota_label="Осталось 73%", + ) + snapshot = HubSnapshot( + generation=1, + seq=1, + timestamp=time.time(), + profiles_by_provider={}, + all_profiles={}, + readiness=_readiness(), + agents=[agent], + providers=[], + routing={}, + quotas={ + "ag-w1": QuotaSnapshot( + account_id="ag-w1", + provider="antigravity", + source="provider_api", + buckets=[ + QuotaBucket( + id="antigravity.gemini.7d", + display_name="Gemini • неделя", + model_family="gemini", + remaining_percent=73.0, + ) + ], + ) + }, + ) + + label, percent = DashboardView._agent_quota_measurement(snapshot, agent) + + assert label == "Осталось 73%" + assert percent == 73.0 + + @pytest.mark.ui def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None: snapshot = HubSnapshot( @@ -215,6 +292,54 @@ def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None: view.destroy() +@pytest.mark.ui +def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_root) -> None: + profiles = [ + replace( + _profile(), + profile_id=f"ag-{index}", + account_identity=f"user{index}@example.test", + email=f"user{index}@example.test", + ) + for index in range(6) + ] + readiness = replace(_readiness(), accounts_connected_count=6, total_accounts=6) + provider = ProviderSummary( + provider_id="antigravity", + provider_name="Google Antigravity", + total_slots=10, + connected_count=6, + online_count=6, + auth_required_count=0, + quota_exhausted_count=0, + cold_spare_count=0, + discovered_models=["gemini-3.7-flash"], + last_refresh_at="12:00:00", + ) + snapshot = HubSnapshot( + generation=1, + seq=1, + timestamp=time.time(), + profiles_by_provider={"antigravity": profiles}, + all_profiles={profile.profile_id: profile for profile in profiles}, + readiness=readiness, + agents=[], + providers=[provider], + routing={}, + quotas={}, + ) + view = DashboardView(ui_root) + try: + view.pack() + view.update_data(snapshot) + 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") + finally: + view.destroy() + + @pytest.mark.unit def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None: snapshot = QuotaSnapshot( diff --git a/tests/test_ui_mockup_redesign.py b/tests/test_ui_mockup_redesign.py index 221d1b9..9298a9f 100644 --- a/tests/test_ui_mockup_redesign.py +++ b/tests/test_ui_mockup_redesign.py @@ -15,6 +15,7 @@ import customtkinter as ctk from antigravity_provider.router.ui.components import AccountCardWidget from antigravity_provider.router.ui.theme import Theme +from antigravity_provider.router import hermes_hub_app as app_module @pytest.fixture(scope="module") @@ -124,3 +125,23 @@ def test_restored_account_buttons_invoke_each_action(ui_root) -> None: assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS] finally: card.destroy() + + +@pytest.mark.ui +def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None: + policy = SimpleNamespace(preferred_chain=[]) + monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"orchestrator": policy})) + monkeypatch.setattr( + app_module.AutoAssigner, + "assign_profile_to_role", + lambda *_args, **_kwargs: (False, "Профиль не найден"), + ) + ui_root._show_account_action_result = lambda *_args: None + modal = app_module.HermesHubApp._open_assign_role_modal(ui_root, "missing", "Claude") + try: + modal.save_button.invoke() + modal.update_idletasks() + assert modal.winfo_exists() + assert "Профиль не найден" in modal.result_label.cget("text") + finally: + modal.destroy() diff --git a/tests/test_ui_phase2_6.py b/tests/test_ui_phase2_6.py index 80993ee..a9c39b7 100644 --- a/tests/test_ui_phase2_6.py +++ b/tests/test_ui_phase2_6.py @@ -125,6 +125,7 @@ def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) ui_root.update_idletasks() before = view.render_stats() card_ids = {key: id(card) for key, card in view._cards.items()} + assert all(card.compact for card in view._cards.values()) view.update_data(_snapshot(changed_remaining=42.0)) ui_root.update_idletasks() diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py new file mode 100644 index 0000000..658cac4 --- /dev/null +++ b/tests/test_ui_routing_graph.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +pytest.importorskip("customtkinter") + +from antigravity_provider.router.ui import routing_graph as graph_module +from antigravity_provider.router.ui import add_account_wizard as wizard_module +from antigravity_provider.router.ui.views import team_view as team_module +from antigravity_provider.router import hermes_hub_app as app_module +from antigravity_provider.router.ui.routing_graph import ( + GraphEdge, + GraphNode, + RoutingGraph, + RoutingGraphController, + RoutingGraphStore, + default_graph, + validate_graph, +) + + +def _config(): + profiles = { + "orch": SimpleNamespace(provider="openai-codex", preferred_models=["gpt-5"]), + "coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]), + } + roles = { + "orchestrator": SimpleNamespace(preferred_chain=["orch"]), + "coder-primary": SimpleNamespace(preferred_chain=["coder"]), + "coder-secondary": SimpleNamespace(preferred_chain=["coder"]), + "reviewer": SimpleNamespace(preferred_chain=["coder"]), + "research": SimpleNamespace(preferred_chain=["coder"]), + "fast": SimpleNamespace(preferred_chain=["coder"]), + } + 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_default_graph_migrates_six_roles_without_changing_chains(): + config = _config() + before = {key: list(value.preferred_chain) for key, value in config.roles.items()} + graph = default_graph(config) + assert {node.role_id for node in graph.nodes} == set(config.roles) + assert {edge.edge_type for edge in graph.edges} == {"DELEGATE"} + assert before == {key: value.preferred_chain for key, value in config.roles.items()} + + +def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path): + path = tmp_path / "routing_graph.json" + store = RoutingGraphStore(path) + graph = default_graph(_config()) + graph.nodes[0].x = 777 + graph.zoom = 1.35 + graph.viewport_x = 42 + store.save(graph) + loaded = store.load(_config()) + assert loaded.nodes[0].x == 777 + assert loaded.zoom == 1.35 + assert loaded.viewport_x == 42 + assert loaded.schema_version == 1 + + +def test_validation_finds_cycle_unreachable_and_missing_profile(): + config = _config() + config.roles["reviewer"].preferred_chain = ["ghost"] + graph = RoutingGraph( + nodes=[ + GraphNode("orchestrator", 0, 0), + GraphNode("coder-primary", 1, 0), + GraphNode("reviewer", 2, 0), + ], + edges=[ + GraphEdge("orchestrator", "coder-primary"), + GraphEdge("coder-primary", "orchestrator"), + ], + ) + codes = {issue.code for issue in validate_graph(graph, config)} + assert {"cycle", "unreachable", "missing-profile"} <= codes + + +def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(graph_module, "load_router_config", _config) + monkeypatch.setattr( + graph_module.AutoAssigner, + "assign_profile_to_role", + lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"), + ) + controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json")) + ok, _message = controller.add_edge("orchestrator", "coder-primary", "FALLBACK", "orch") + assert ok + assert calls == [("orch", "coder-primary", False)] + + +def test_undo_redo_and_dirty_state(monkeypatch, tmp_path): + monkeypatch.setattr(graph_module, "load_router_config", _config) + controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json")) + original = controller.graph.nodes[0].x + controller.move_node("orchestrator", original + 100, 50) + assert controller.dirty + assert controller.undo() + assert controller.graph.nodes[0].x == original + assert controller.redo() + assert controller.graph.nodes[0].x == original + 100 + + +def test_graph_store_handles_twenty_nodes(tmp_path): + graph = RoutingGraph(nodes=[GraphNode(f"role-{index}", index * 70, index * 35) for index in range(20)]) + store = RoutingGraphStore(tmp_path / "routing_graph.json") + store.save(graph) + assert len(store.load(_config()).nodes) == 20 + + +def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch): + config = _config() + calls = [] + monkeypatch.setattr(wizard_module, "load_router_config", lambda: config) + monkeypatch.setattr( + wizard_module.AutoAssigner, + "assign_profile_to_role", + lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"), + ) + assert wizard_module.ensure_profile_in_routing("orch")[0] + assert calls == [] + monkeypatch.setattr( + wizard_module.AutoAssigner, + "get_display_name_and_role", + lambda _profile: ("Новый кодер", "coder", "fallback"), + ) + assert wizard_module.ensure_profile_in_routing("new-slot")[0] + assert calls == [("new-slot", "coder", False)] + + +def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch): + profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected") + config = SimpleNamespace(get_profile=lambda _profile_id: profile) + + class Adapter: + @staticmethod + def health_check(_profile): + return True + + @staticmethod + def invoke(*_args, **_kwargs): + raise AssertionError("profile test must never invoke inference") + + monkeypatch.setattr(app_module, "load_router_config", lambda: config) + monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True}) + monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"}) + monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter()) + monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None)) + + result = app_module.do_test_profile("antigravity", "connected") + assert result["success"] is True + assert "runtime" in result["response"] + + +def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch): + calls = [] + monkeypatch.setattr(wizard_module, "ensure_profile_in_routing", lambda _profile: (True, "ok")) + monkeypatch.setattr( + wizard_module.EventLogService, + "get", + lambda: SimpleNamespace(log=lambda *args, **kwargs: calls.append(("log", args, kwargs))), + ) + from antigravity_provider.router import router_engine + + monkeypatch.setattr( + router_engine, + "get_router_engine", + lambda: SimpleNamespace(health=SimpleNamespace(clear_cooldown=lambda profile: calls.append(("clear", profile)))), + ) + fake = SimpleNamespace( + target_slot="ag-orch-fallback", + selected_provider="antigravity", + discovered_identity="account", + finish_status_lbl=SimpleNamespace(configure=lambda **_kwargs: None), + on_complete=lambda payload: calls.append(("complete", payload)), + destroy=lambda: calls.append(("destroy",)), + ) + wizard_module.AddAccountWizard._finish(fake) + assert ("clear", "ag-orch-fallback") in calls + assert any(item[0] == "log" for item in calls) + assert calls[-1] == ("destroy",) + + +def test_wizard_stops_when_provider_has_no_real_free_slot(monkeypatch): + calls = [] + monkeypatch.setattr(wizard_module.AutoAssigner, "find_free_slot", lambda _provider: None) + fake = SimpleNamespace( + selected_provider="grok", + target_slot="old-value", + _clear_body=lambda: calls.append("clear"), + title_lbl=SimpleNamespace(configure=lambda **_kwargs: None), + _show_no_free_slot=lambda: calls.append("no-slot"), + ) + + wizard_module.AddAccountWizard._show_step_2_auth(fake) + + assert fake.target_slot == "" + assert calls == ["clear", "no-slot"] + + +def test_wizard_finish_without_slot_shows_error_and_does_not_assign(monkeypatch): + updates = [] + monkeypatch.setattr( + wizard_module, + "ensure_profile_in_routing", + lambda _profile: pytest.fail("an invented or empty slot must not be assigned"), + ) + fake = SimpleNamespace( + target_slot="", + finish_status_lbl=SimpleNamespace(configure=lambda **kwargs: updates.append(kwargs)), + ) + + wizard_module.AddAccountWizard._finish(fake) + + assert "свободный слот" in updates[-1]["text"] + + +def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch): + config = SimpleNamespace( + profiles={key: SimpleNamespace() for key in ("a", "b", "c")}, + roles={ + "orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]), + "reviewer": SimpleNamespace(preferred_chain=["b"]), + }, + ) + calls = [] + + def assign(profile_id, role_id, is_primary=True): + calls.append((profile_id, role_id, is_primary)) + if role_id == "spare": + for policy in config.roles.values(): + policy.preferred_chain = [item for item in policy.preferred_chain if item != profile_id] + return True, "spare" + chain = config.roles[role_id].preferred_chain + chain = [item for item in chain if item != profile_id] + if is_primary: + chain.insert(0, profile_id) + else: + chain.append(profile_id) + config.roles[role_id].preferred_chain = chain + return True, "assigned" + + monkeypatch.setattr(team_module, "load_router_config", lambda: config) + monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign) + + ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"]) + + assert ok + assert config.roles["orchestrator"].preferred_chain == ["c", "a"] + assert config.roles["reviewer"].preferred_chain == ["b"] + assert ("b", "spare", False) in calls + + +def test_account_action_result_is_sent_to_originating_card(): + calls = [] + accounts = SimpleNamespace( + show_action_result=lambda profile_id, message, success: calls.append( + ("card", profile_id, message, success) + ) + ) + fake = SimpleNamespace( + _views={"accounts": accounts}, + _show_toast=lambda message: calls.append(("toast", message)), + ) + + app_module.HermesHubApp._show_account_action_result(fake, "profile-1", "Не найден", False) + + assert calls[0] == ("card", "profile-1", "Не найден", False) + assert calls[1][0] == "toast" + + +def test_device_code_step_contains_numbered_instructions_and_copy_actions(): + source = Path(wizard_module.__file__).read_text(encoding="utf-8") + assert "1. Откройте ссылку" in source + assert "2. Введите на странице код:" in source + assert "3. Подтвердите доступ" in source + assert source.count('text="Копировать ссылку"') == 2 + assert source.count('text="📋 Копировать код"') == 2 + + +def test_grok_slot_is_registered_before_role_assignment(monkeypatch): + config = SimpleNamespace(profiles={}) + saved = [] + monkeypatch.setattr(wizard_module, "load_router_config", lambda: config) + monkeypatch.setattr( + "antigravity_provider.router.auto_assigner.load_router_config", + lambda: config, + ) + monkeypatch.setattr( + "antigravity_provider.router.auto_assigner.save_router_config", + lambda current: saved.append(current) or True, + ) + + ok, _message = wizard_module.AutoAssigner.ensure_profile_definition("grok", "grok-orch") + + assert ok + assert config.profiles["grok-orch"].provider == "grok" + assert config.profiles["grok-orch"].preferred_models[0] == "grok-3" + assert saved == [config] + + +def test_opencode_paste_targets_entry_and_reports_success(): + calls = [] + entry = SimpleNamespace( + clipboard_get=lambda: " opencode-token-123 ", + delete=lambda *_args: calls.append("delete"), + insert=lambda *_args: calls.append(("insert", _args[-1])), + focus_set=lambda: calls.append("focus"), + icursor=lambda *_args: calls.append("cursor"), + ) + status = SimpleNamespace(configure=lambda **kwargs: calls.append(("status", kwargs["text"]))) + fake = SimpleNamespace(key_entry=entry, key_status_lbl=status) + + wizard_module.AddAccountWizard._paste_into_entry(fake, entry) + + assert ("insert", "opencode-token-123") in calls + assert ("status", "✓ Ключ вставлен. Нажмите «Проверить и продолжить».") in calls diff --git a/tests/test_unified_health.py b/tests/test_unified_health.py index 8c1a11c..2f44698 100644 --- a/tests/test_unified_health.py +++ b/tests/test_unified_health.py @@ -109,7 +109,7 @@ def test_system_readiness_calculation(): assert isinstance(readiness, SystemReadiness) assert readiness.state in (READINESS_HEALTHY, READINESS_LIMITED, READINESS_DEGRADED, READINESS_CRITICAL) assert readiness.total_roles > 0 - assert readiness.total_accounts >= 16 + assert readiness.total_accounts >= readiness.accounts_connected_count assert readiness.title_ru assert readiness.summary_ru