diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 7beb300..3360aa4 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -172,6 +172,48 @@ class AutoAssigner: return candidates[0] if candidates else 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/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index 020dd9d..691dc36 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 @@ -231,6 +231,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) ── @@ -263,8 +275,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"), @@ -595,28 +607,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( @@ -639,11 +656,7 @@ class HermesHubApp(ctk.CTk): ) elif action == "edit_route": role_id = data.get("role_id", "") - self._show_view("team") - team = self._views.get("team") - if team and hasattr(team, "focus_role"): - team.focus_role(role_id) - self._show_toast(f"Настройка цепочки роли: {role_id}") + self._open_route_editor_modal(role_id) elif action == "open_routing": self._show_view("routing") routing = self._views.get("routing") @@ -724,6 +737,18 @@ class HermesHubApp(ctk.CTk): 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( @@ -731,12 +756,279 @@ class HermesHubApp(ctk.CTk): chosen, is_primary=primary_var.get() and chosen != "spare", ) - modal.destroy() - self._show_toast(f"✅ {msg}" if ok else f"❌ {msg}") - self._refresh_data() + 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)) + + provider_models = { + "antigravity": [ + "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 _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 + provider_models.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) @@ -745,12 +1037,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.""" diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index e7ea635..bb0a314 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 @@ -164,17 +166,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 +312,99 @@ 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") + + project_id = auth_data.get("project_id") or auth_data.get("projectId") + if not project_id: + project_id = load_or_onboard_project(str(access_token)) + + def _fetch(token: str) -> dict[str, Any]: + request = urllib.request.Request( + "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + 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 "{}") + + try: + payload = _fetch(str(access_token)) + except urllib.error.HTTPError as exc: + refresh_token = token_data.get("refresh_token") or token_data.get("refresh") + if exc.code != 401 or not refresh_token: + raise + refreshed = refresh_access_token(str(refresh_token)) + token_data.update(refreshed) + auth_data["token"] = token_data + auth_data["project_id"] = project_id + ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data) + payload = _fetch(str(refreshed["access_token"])) + + 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: diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index 45876f6..adab18a 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -212,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() @@ -236,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 # ───────────────────────────────────────────────────────────── @@ -484,22 +515,10 @@ class AddAccountWizard(HubModal): ctk.CTkLabel( auth_card, - text=( - "1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице OpenAI. " - "3. Вернитесь в Hub — поле для этого кода в приложении не требуется." - ), - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - wraplength=520, - justify="left", - ).pack(anchor="w", padx=10, pady=(7, 2)) - - 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)) @@ -516,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") @@ -528,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)) @@ -536,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)) @@ -561,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)) @@ -779,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") @@ -940,22 +966,10 @@ class AddAccountWizard(HubModal): ctk.CTkLabel( auth_card, - text=( - "1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице xAI. " - "3. Вернитесь в Hub — поле для этого кода в приложении не требуется." - ), - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - wraplength=520, - justify="left", - ).pack(anchor="w", padx=10, pady=(7, 2)) - - 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)) @@ -984,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)) @@ -992,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)) @@ -1017,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)) @@ -1277,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, @@ -1359,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 @@ -1512,6 +1541,19 @@ 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. diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index 3f4f208..29e6472 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -888,6 +888,31 @@ 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_labels = [ + ctk.CTkLabel( + self.compact_quota, + text="", + font=Theme.font_micro(), + text_color=Theme.TEXT_SECONDARY, + anchor="w", + ) + for _ in range(4) + ] + for index, label in enumerate(self.compact_quota_labels): + label.grid(row=index // 2, column=index % 2, sticky="ew", padx=(0, 8), pady=2) + self.compact_quota.grid_columnconfigure(index % 2, weight=1) + self.compact_actions = ctk.CTkFrame(self, fg_color="transparent") + for text, action in (("⚡", "test"), ("↻", "refresh_account"), ("Роль", "assign_role")): + 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") @@ -897,6 +922,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)) @@ -944,8 +978,19 @@ 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) @@ -954,7 +999,11 @@ class AccountCardWidget(HubCard): self.toggle.configure(text="▸" if compact else "▾") if compact: 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)) else: + self.compact_quota.pack_forget() + self.compact_actions.pack_forget() self.details.pack(fill="x") def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None: @@ -981,6 +1030,22 @@ class AccountCardWidget(HubCard): 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 + for index, label in enumerate(self.compact_quota_labels): + if index < len(buckets): + bucket = buckets[index] + label.configure( + text=f"{bucket.display_name}: {bucket.formatted_remaining()}", + text_color=( + Theme.STATUS_HEALTHY + if getattr(bucket, "status", "unknown") == "healthy" + else Theme.STATUS_WARNING + if getattr(bucket, "status", "unknown") == "warning" + else Theme.TEXT_MUTED + ), + ) + label.grid() + else: + label.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/views/accounts_view.py b/src/antigravity_provider/router/ui/views/accounts_view.py index d1d90f7..bf5ac17 100644 --- a/src/antigravity_provider/router/ui/views/accounts_view.py +++ b/src/antigravity_provider/router/ui/views/accounts_view.py @@ -203,6 +203,7 @@ class AccountsView(ctk.CTkFrame): return self._snapshot = snapshot live_ids = {profile.profile_id for profile in self._profiles()} + collapse_new_collection = len(self._cards) <= 1 and len(live_ids) > 1 for profile_id in list(self._cards): if profile_id not in live_ids: self._cards.pop(profile_id).destroy() @@ -220,11 +221,15 @@ class AccountsView(ctk.CTkFrame): profile.profile_id, AccountCardWidget.resolve_identity(profile), profile.provider_display_name, + compact=len(live_ids) > 1, on_action=self._emit, ) self._cards[profile.profile_id] = card self.cards_created += 1 card.update_account(profile, snapshot.quotas.get(profile.profile_id)) + if collapse_new_collection: + for card in self._cards.values(): + card.set_compact(True) self._render_visibility() def _render_visibility(self) -> None: @@ -267,3 +272,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 dc235bd..418237e 100644 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ b/src/antigravity_provider/router/ui/views/dashboard_view.py @@ -93,6 +93,21 @@ class _EndpointCard(HubCard): ) 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) @@ -184,12 +199,12 @@ class _RouteDiagram(ctk.CTkFrame): ) 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 +212,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 +238,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, @@ -401,7 +418,7 @@ class DashboardView(ctk.CTkFrame): 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( @@ -411,10 +428,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 @@ -422,8 +438,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, @@ -503,7 +521,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: @@ -552,9 +570,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 "Н/Д", @@ -576,13 +594,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), @@ -601,7 +622,7 @@ 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] + agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5] self._sync_endpoint_cards( self.route_diagram.agent_slots, self._agent_cards, @@ -619,6 +640,21 @@ class DashboardView(ctk.CTkFrame): for agent in agents ), ) + 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") @@ -641,11 +677,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 7eeb847..83dcef3 100644 --- a/src/antigravity_provider/router/ui/views/routing_view.py +++ b/src/antigravity_provider/router/ui/views/routing_view.py @@ -27,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}), diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py index 0ae61a8..92129f3 100644 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ b/src/antigravity_provider/router/ui/views/team_view.py @@ -25,6 +25,7 @@ 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, @@ -38,6 +39,37 @@ 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): """Reusable agent card that updates in-place without rebuilding widgets.""" @@ -128,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 @@ -651,11 +704,89 @@ class TeamView(ctk.CTkFrame): 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, + ) + 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") + + 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() + + 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) + + 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) + + 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) + def _on_runtime_event(self, name: str, data: Any) -> None: # EventBus may publish from a worker. Tk mutation is always marshalled. try: diff --git a/tests/test_accounts_tariffs_quotas.py b/tests/test_accounts_tariffs_quotas.py index 31313dc..9b47450 100644 --- a/tests/test_accounts_tariffs_quotas.py +++ b/tests/test_accounts_tariffs_quotas.py @@ -187,13 +187,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 +218,8 @@ 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_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path): diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py index 3a93fd2..3605bf1 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, ) @@ -215,6 +217,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 index 7cfdfcf..b37d5fa 100644 --- a/tests/test_ui_routing_graph.py +++ b/tests/test_ui_routing_graph.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace import pytest @@ -8,6 +9,7 @@ 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, @@ -182,3 +184,139 @@ def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch): 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