From eaae2f8ac4f64f40cbd78c676fa19095238b8dd2 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Sun, 23 Aug 2026 19:54:29 +0700 Subject: [PATCH] =?UTF-8?q?fix(A15):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=BF=20=D0=B8=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B2=D0=B5=D0=B1-API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки при приёмке A15. Веб-API и вынесение действий приняты, но в сданном виде не работали ни то, ни другое. 1. Десктоп был уничтожен. При выносе действий из hermes_hub_app.py пропало объявление class HermesHubApp вместе с 13 методами каркаса: __init__, _build_layout, _create_view, _show_view, _refresh_data и другими. Оставшиеся 14 методов оказались вложены внутрь функции _load_saved_theme после её return — синтаксически валидный недостижимый код, поэтому модуль импортировался и дефект выглядел безобидно. launch_hub() при этом падал бы с NameError. hermes_hub_app.py восстановлен из main; задание прямо требовало десктоп не ломать. 2. Дублирование убрано правильным способом: десктоп импортирует пять do_* из action_handler, второй реализации в проекте нет. 3. Веб-API падал с 500 на обоих значимых эндпоинтах: get_auth_token и run_server читали config.hub, а такого атрибута у RouterConfig нет. Настройки живут в hub_settings.json. Работал только /api/health, у которого нет проверки авторизации, — из-за чего сервер и выглядел поднявшимся. 4. do_save_settings при переносе потеряла атомарную запись через os.replace, ensure_ascii=False и вызов set_refresh_interval, то есть интервал обновления квот из настроек перестал применяться. Восстановлено. 5. Импорт адаптера был убран внутрь do_test_profile, что делало функцию неподменяемой в тестах. Поднят на уровень модуля. 6. Версия в /api/health была зашита как "1.0.0" вместо настоящей. Проверено исполнением: /api/snapshot отдаёт 200 и 12 ключей, полностью совпадающих с docs/web-api/snapshot.example.json; секретов в ответе нет; неизвестное действие даёт 404. Тесты: 319 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- .../router/action_handler.py | 28 +- .../router/hermes_hub_app.py | 564 ++++++++++++++++-- src/antigravity_provider/router/web/server.py | 37 +- tests/test_ui_claude_grok_connection.py | 5 +- tests/test_ui_routing_graph.py | 13 +- 5 files changed, 559 insertions(+), 88 deletions(-) diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index f9e8a99..069d734 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -1,5 +1,6 @@ import time import json +import os import logging import threading from typing import Any, Dict, Tuple, Optional, Callable @@ -11,6 +12,7 @@ from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.scheduler import HermesRefreshScheduler from antigravity_provider.updater import UpdateManager from antigravity_provider import paths +from antigravity_provider.router.adapters import get_adapter logger = logging.getLogger('hermes.router.actions') @@ -31,7 +33,6 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]: return ok, msg def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: - from antigravity_provider.router.adapters import get_adapter config = load_router_config() pcfg = config.get_profile(profile_id) if not pcfg: @@ -84,21 +85,30 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]: return True, 'Учетные данные отсутствовали' def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: - settings_file = paths.get_hermes_home() / 'hub_settings.json' + settings_file = paths.get_hermes_home() / "hub_settings.json" settings_file.parent.mkdir(parents=True, exist_ok=True) existing: Dict[str, Any] = {} if settings_file.exists(): try: - existing = json.loads(settings_file.read_text(encoding='utf-8')) + existing = json.loads(settings_file.read_text(encoding="utf-8")) except Exception: - pass - for k, v in settings.items(): - existing[k] = v + existing = {} + existing.update(settings) try: - settings_file.write_text(json.dumps(existing, indent=2), encoding='utf-8') - return True, 'Настройки сохранены' + # Запись через временный файл и os.replace: обрыв на середине не + # должен оставить настройки битыми. В домашнем каталоге Hermes уже + # лежит config.yaml.corrupt.<дата>.bak — этот риск не теоретический. + temp_file = settings_file.with_suffix(".json.tmp") + temp_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8") + os.replace(temp_file, settings_file) except Exception as e: - return False, f'Не удалось сохранить настройки: {e}' + return False, f"Не удалось сохранить настройки: {e}" + + from antigravity_provider.router.quota_collector import AccountQuotaService + + AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300))) + return True, "Настройки сохранены" + class ActionExecutor: """Shared execution layer for Desktop and Web actions.""" diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index ef80c56..38fc836 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -34,14 +34,10 @@ if sys.platform == "win32": pass # ── Ensure plugin and repo paths are on sys.path ── -_SRC_DIR = Path(__file__).resolve().parent.parent.parent -if str(_SRC_DIR) not in sys.path: - sys.path.insert(0, str(_SRC_DIR)) -import antigravity_provider.paths as _paths -_HERMES_HOME = _paths.get_hermes_home() -_PLUGIN_SRC = _HERMES_HOME / "plugins" / "antigravity-provider" / "src" -_AGENT_DIR = _HERMES_HOME / "hermes-agent" -for _p in [_PLUGIN_SRC, _AGENT_DIR]: +_LOCAL = Path(os.environ.get("LOCALAPPDATA", "")) +_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src" +_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent" +for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]: _ps = str(_p) if _p.exists() and _ps not in sys.path: sys.path.insert(0, _ps) @@ -50,6 +46,15 @@ from antigravity_provider.router.router_config import load_router_config, save_r from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.adapters import get_adapter +# Единственная реализация действий живёт в action_handler: её используют +# и десктоп, и веб-API. Второй копии в проекте быть не должно. +from antigravity_provider.router.action_handler import ( + do_delete_credentials, + do_save_settings, + do_set_main, + do_set_orchestrator, + do_test_profile, +) from antigravity_provider import paths from antigravity_provider.version import __version__ @@ -89,78 +94,513 @@ def _load_saved_theme() -> str: return str(settings.get("theme", "dark")) +# ═══════════════════════════════════════════════════════════════ +# Actions Layer (Safe & Non-blocking) +# ═══════════════════════════════════════════════════════════════ + + +class HermesHubApp(ctk.CTk): + def __init__(self): + self._theme_name = Theme.apply_scheme(_load_saved_theme()) + ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light") + super().__init__() + self.title("Hermes Hub") + self.geometry("1380x880") + self.minsize(1100, 700) + + ctk.set_default_color_theme("blue") + self.configure(fg_color=Theme.BG_WINDOW) + + # Set Windows Multi-Resolution Icon + ico_path = AssetManager.get().get_ico_path() + if ico_path and os.path.exists(ico_path): + try: + self.iconbitmap(ico_path) + except Exception: + pass + + self._current_view = "overview" + self._views: Dict[str, ctk.CTkFrame] = {} + self._view_generations: Dict[str, int] = {} + self._shutting_down = False + self._resize_timer_id = None + + self.protocol("WM_DELETE_WINDOW", self._on_close) + self.bind("", self._on_window_configure) + + self._build_layout() + self._show_view("overview") + + try: + from antigravity_provider.router.scheduler import HermesRefreshScheduler + + HermesRefreshScheduler.get().start() + except Exception: + pass + + try: + from antigravity_provider.router.quota_collector import AccountQuotaService + + AccountQuotaService.get().start_background_scheduler() + except Exception: + 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) ── + self.sidebar = ctk.CTkFrame(self, width=Theme.WIDTH_SIDEBAR, fg_color=Theme.BG_SIDEBAR, corner_radius=0) + self.sidebar.pack(side="left", fill="y") + self.sidebar.pack_propagate(False) + + # Top Centered Brand Logo + brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent") + brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM)) + + logo_img = AssetManager.get().get_logo_image(size=(78, 78)) + if logo_img: + logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="") + logo_lbl.pack(anchor="center", pady=(0, 6)) + + ctk.CTkLabel( + brand_container, + text="HERMES HUB", + font=(Theme.FONT_FAMILY_TITLE, 17, "bold"), + text_color=Theme.TEXT_ACCENT, + ).pack(anchor="center") + ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack( + fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM) + ) + + # Nav Items with clean Fluent glyphs + self._nav_items = [ + ("overview", "Обзор", "overview"), + ("team", "Команда", "team"), + ("accounts", "Аккаунты", "accounts"), + ("routing", "Маршрутизация", "routing"), + ("providers", "Модели и провайдеры", "providers"), + ("analytics", "Аналитика", "analytics"), + ("health", "Состояние", "health"), + ("logs", "Журнал событий", "logs"), + ("settings", "Настройки", "settings"), + ("about", "О программе", "about"), + ] + + self.nav_frame = ctk.CTkScrollableFrame( + self.sidebar, + fg_color="transparent", + corner_radius=0, + scrollbar_fg_color=Theme.BG_SIDEBAR, + scrollbar_button_color=Theme.BG_SIDEBAR, + scrollbar_button_hover_color=Theme.BORDER_HOVER, + ) + self.nav_frame.pack(fill="both", expand=True) + self._nav_buttons: Dict[str, ctk.CTkButton] = {} + for key, label, icon in self._nav_items: + icon_image = AssetManager.get().get_nav_icon(icon, size=19) + btn = ctk.CTkButton( + self.nav_frame, + text=label, + image=icon_image, + compound="left", + font=Theme.font_body(), + height=Theme.HEIGHT_NAV_ITEM, + fg_color="transparent", + hover_color=Theme.SIDEBAR_HOVER, + text_color=Theme.SIDEBAR_TEXT, + anchor="w", + corner_radius=Theme.RADIUS_SM, + command=lambda k=key: self._show_view(k), + ) + btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1) + self._nav_buttons[key] = btn + + self.sidebar_version = ctk.CTkLabel( + self.sidebar, + text=f"Hermes Hub v{__version__}", + font=Theme.font_micro(), + text_color=Theme.SIDEBAR_MUTED, + ) + self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM)) + user_card = ctk.CTkFrame( + self.sidebar, + fg_color=Theme.SIDEBAR_SELECTED, + border_width=1, + border_color=Theme.BORDER, + corner_radius=Theme.RADIUS_MD, + ) + user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM) + ctk.CTkLabel( + user_card, + text="AD", + width=30, + height=30, + corner_radius=15, + fg_color=Theme.ACCENT, + text_color=Theme.TEXT_ON_ACCENT, + font=Theme.font_badge_bold(), + ).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM) + ctk.CTkLabel( + user_card, + text="Administrator\nОсновная команда", + justify="left", + font=Theme.font_micro(), + text_color=Theme.SIDEBAR_TEXT, + ).pack(side="left") + + # ── Global top bar ── + self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0) + self.statusbar.pack(side="top", fill="x") + self.statusbar.pack_propagate(False) + + self.status_left = ctk.CTkLabel( + self.statusbar, + text="● Состояние загружается", + font=Theme.font_caption(), + 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, + placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K", + width=360, + height=Theme.HEIGHT_INPUT, + fg_color=Theme.SURFACE, + border_color=Theme.BORDER, + text_color=Theme.TEXT_PRIMARY, + ) + self.global_search.pack(side="left", padx=Theme.SPACE_MD) + self.global_search.bind("", self._run_global_search) + self.bind_all("", self._focus_global_search) + + for icon_name, command in ( + ("settings", lambda: self._show_view("settings")), + ("about", lambda: self._show_view("about")), + ("logs", lambda: self._show_view("logs")), + ): + HubButton( + self.statusbar, + text="", + image=AssetManager.get().get_nav_icon(icon_name, size=18), + variant="ghost", + width=Theme.HEIGHT_BTN_MD, + command=command, + ).pack(side="right", padx=Theme.SPACE_XS) + self.add_account_button = HubButton( + self.statusbar, + text="+ Добавить аккаунт", + variant="primary", + command=lambda: self._handle_action("add_account", {}), + ) + self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_MD)) + + self.status_right = ctk.CTkLabel( + self.statusbar, + text="Snapshot: Н/Д", + font=Theme.font_micro(), + text_color=Theme.TEXT_MUTED, + ) + + # ── Main Content Area ── + self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0) + self.content.pack(side="right", fill="both", expand=True) + + # Pre-instantiate all views so switching is 100% instant (0-15 ms) + for key, _, _ in self._nav_items: + self._views[key] = self._create_view(key) + + def _focus_global_search(self, _event=None) -> str: + self.global_search.focus_set() + return "break" + + def _run_global_search(self, _event=None) -> str: + query = self.global_search.get().strip() + self._show_view("accounts") + accounts = self._views.get("accounts") + if accounts and hasattr(accounts, "search"): + accounts.search.delete(0, "end") + accounts.search.insert(0, query) + accounts._set_search(query) + return "break" + + def _create_view(self, view_name: str) -> ctk.CTkFrame: + """Create view widget instance.""" + if view_name == "overview": + return DashboardView( + self.content, + app_state={}, + on_navigate=self._show_view, + on_action=self._handle_action, + ) + elif view_name == "team": + return TeamView(self.content, app_state={}, on_action=self._handle_action) + elif view_name == "accounts": + return AccountsView(self.content, app_state={}, on_action=self._handle_action) + elif view_name == "providers": + return ProvidersView(self.content, app_state={}, on_action=self._handle_action) + elif view_name == "routing": + return RoutingView(self.content, on_action=self._handle_action) + elif view_name == "analytics": + return AnalyticsView(self.content) + elif view_name == "health": + return HealthView(self.content, app_state={}, on_refresh=self._refresh_data) + elif view_name == "logs": + return LogsView(self.content) + elif view_name == "settings": + return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name) + elif view_name == "about": + return AboutView(self.content) + else: + return TeamView(self.content, app_state={}, on_action=self._handle_action) + + def _show_view(self, view_name: str): + """Instant view switching using pack_forget() and cached widgets with lazy generation update.""" + t0 = time.time() + prev_view = self._current_view + self._current_view = view_name + + # Update sidebar button states + for key, btn in self._nav_buttons.items(): + if key == view_name: + btn.configure( + fg_color=Theme.SIDEBAR_SELECTED, + text_color=Theme.TEXT_ACCENT, + border_width=1, + border_color=Theme.BORDER_ACCENT, + ) + else: + btn.configure( + fg_color="transparent", + text_color=Theme.SIDEBAR_TEXT, + border_width=0, + ) + + # Hide currently active views + for v in self._views.values(): + v.pack_forget() + + # Show target view instantly + target_view = self._views.get(view_name) + if target_view: + target_view.pack(fill="both", expand=True) + + # Lazy update if view state is behind current snapshot generation + from antigravity_provider.router.state_store import HubStateStore + + snap = HubStateStore.get().get_snapshot() + if self._view_generations.get(view_name, 0) < snap.generation: + if hasattr(target_view, "update_data"): + try: + target_view.update_data(snap) + except Exception as ex: + logger.warning("Error in lazy view update for %s: %s", view_name, ex) + self._view_generations[view_name] = snap.generation + self._update_auxiliary_data(target_view) + + # Instrument tab switch latency + el_ms = round((time.time() - t0) * 1000, 2) + if el_ms > 100: + logger.warning(f"[TAB SWITCH SLOW] {prev_view} -> {view_name}: {el_ms} ms") + else: + logger.debug(f"[TAB SWITCH] {prev_view} -> {view_name}: {el_ms} ms") + + def _on_window_configure(self, event): + """Debounce window resize to maintain 60fps smoothness.""" + if event.widget != self: + return + if self._resize_timer_id: + try: + self.after_cancel(self._resize_timer_id) + except Exception: + pass + self._resize_timer_id = self.after(100, self._handle_debounced_resize) + + def _handle_debounced_resize(self): + self._resize_timer_id = None + + # ─────── Data Refresh (Threaded via Scheduler & HubStateStore) ─────── + + def _refresh_data(self): + if self._shutting_down: + return + try: + self.status_left.configure(text="Обновление состояния...") + except Exception: + pass + + def _load(): + if self._shutting_down: + return + try: + from antigravity_provider.router.state_store import HubStateStore + + snap = HubStateStore.get().refresh(force_scan=True) + if not self._shutting_down: + self.after(0, lambda: self._on_data_loaded(snap)) + except Exception as e: + if not self._shutting_down: + try: + self.after(0, lambda err=str(e): self._on_data_error(err)) + except Exception: + pass + + threading.Thread(target=_load, daemon=True).start() + + def _on_data_loaded(self, snapshot_or_readiness: Any): + if self._shutting_down: + return + + from antigravity_provider.router.state_store import HubSnapshot, HubStateStore + + if isinstance(snapshot_or_readiness, HubSnapshot): + snap = snapshot_or_readiness + readiness = snap.readiness + else: + snap = HubStateStore.get().get_snapshot() + readiness = snapshot_or_readiness + + freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}" + self.status_left.configure( + text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}", + text_color=Theme.STATUS_HEALTHY + if readiness.state == "healthy" + else Theme.STATUS_WARNING + if readiness.state in ("limited", "degraded") + else Theme.STATUS_ERROR, + ) + + self.status_right.configure( + text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей", + text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED, + ) + + # Update ONLY the currently visible view (others are updated lazily on tab switch) + curr_view = self._views.get(self._current_view) + if curr_view and hasattr(curr_view, "update_data"): + try: + curr_view.update_data(snap) + except Exception as ex: + logger.warning("Error updating current view %s: %s", self._current_view, ex) + self._view_generations[self._current_view] = snap.generation + self._update_auxiliary_data(curr_view) + + @staticmethod + def _update_auxiliary_data(view: Any) -> None: + if hasattr(view, "update_events"): + try: + view.update_events(EventLogService.get().get_events(limit=20)) + except Exception as ex: + logger.warning("Error updating event presentation: %s", ex) + + def _on_data_error(self, error: str): + if self._shutting_down: + return + self.status_left.configure(text=f"Ошибка: {error}") + # ─────── Action Handler ─────── - def _handle_action(self, action: str, data: dict): + def _handle_action(self, action: str, data: Dict[str, Any]): pid = data.get("profile_id", "") - - # 1. UI Navigation - if action in ["oauth", "add_account"]: + prov = data.get("provider", "") + + if action == "set_main": + self._run_in_thread( + lambda: do_set_main(prov, pid), + 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_account_action_result(pid, r[1], r[0]), + ) + elif action == "test": + 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=lambda result: self._show_test_result(result, pid), + ) + elif action == "oauth" or action == "add_account": self._open_add_account_wizard() - return + elif action == "delete_credentials": + self._run_in_thread( + lambda: do_delete_credentials(prov, pid), + 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)) - return elif action == "account_details": self._open_account_details_modal(pid) - return elif action == "agent_settings": - self._open_agent_settings_modal(data.get("role_id", ""), pid) - return + self._open_agent_settings_modal( + data.get("role_id", ""), + pid, + ) + elif action == "auto_assign_all": + self._show_toast("⚡ Автоматическое распределение ролей...") + self._run_in_thread( + lambda: AutoAssigner.auto_assign_all(), + on_success=lambda r: self._show_toast("✅ Роли успешно распределены"), + ) + elif action == "refresh_data": + self._refresh_data() + elif action == "refresh_all": + from antigravity_provider.router.scheduler import HermesRefreshScheduler + + HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data)) + elif action == "refresh_account": + from antigravity_provider.router.scheduler import HermesRefreshScheduler + + HermesRefreshScheduler.get().trigger_refresh_account( + prov, + pid, + on_complete=lambda: self.after(0, self._refresh_data), + ) elif action == "edit_route": - self._open_route_editor_modal(data.get("role_id", "")) - return + 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", "")) - return - elif action == "refresh_data": - self._refresh_data() - return - - # 2. Execution logic via shared handler - from antigravity_provider.router.action_handler import ActionExecutor - - if action == "test": - self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}…", None) - elif action == "auto_assign_all": - self._show_toast("⚡ Автоматическое распределение ролей...") - - def _on_success(res: dict): - ok = res.get("ok", False) - msg = res.get("message", "") - - if action == "test": - self._show_test_result(res.get("data", {}), pid) - elif action == "check_updates": - upd_res = res.get("data") - if upd_res: - self._show_toast( - f"Доступна версия {upd_res.manifest.version}" - if upd_res.update_available and upd_res.manifest - else (f"Ошибка: {upd_res.error}" if upd_res.error else "Установлена актуальная версия") - ) - elif action == "save_settings": + elif action == "save_settings": + + def _settings_saved(result: Tuple[bool, str]) -> None: requested_theme = str(data.get("theme", self._theme_name)) if requested_theme != self._theme_name: self._apply_theme(requested_theme) - self._show_toast(f"✅ {msg}") - elif action in ["refresh_all", "refresh_account"]: - self.after(0, self._refresh_data) - else: - if msg and msg not in ["Навигация", "запущено"]: - prefix = "✅" if ok else "❌" - self._show_toast(f"{prefix} {msg}") - if action in ["set_main", "set_orchestrator", "delete_credentials"]: - self._show_account_action_result(pid, msg, ok) - - # Execute in a thread since Desktop shouldn't block UI - self._run_in_thread( - lambda: ActionExecutor.execute(action, data), - on_success=_on_success - ) + self._show_toast(f"✅ {result[1]}") + + self._run_in_thread( + lambda: do_save_settings(data), + on_success=_settings_saved, + ) + elif action == "check_updates": + from antigravity_provider.updater import UpdateManager + + self._run_in_thread( + lambda: UpdateManager().check_for_updates(), + on_success=lambda result: self._show_toast( + f"Доступна версия {result.manifest.version}" + if result.update_available and result.manifest + else (f"Ошибка: {result.error}" if result.error else "Установлена актуальная версия") + ), + ) def _open_assign_role_modal(self, profile_id: str, display_name: str): modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420) diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index bbb079a..8254602 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -1,10 +1,13 @@ +import json import os import sys import threading import dataclasses import logging -from typing import Dict, Any +from typing import Any, Dict +from antigravity_provider import paths +from antigravity_provider.version import __version__ from fastapi import FastAPI, Request, HTTPException, Depends, Header from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware @@ -26,12 +29,28 @@ app.add_middleware( allow_headers=["*"], ) + +def _web_settings() -> Dict[str, Any]: + """Настройки веб-API живут в hub_settings.json, а не в RouterConfig. + + Прежняя версия читала config.hub — такого атрибута у RouterConfig нет, + поэтому /api/snapshot и /api/action падали с 500, а run_server не + поднимался вовсе. Работал только /api/health, у которого нет проверки + авторизации, — из-за чего дефект и выглядел как рабочий сервер. + """ + settings_file = paths.get_hermes_home() / "hub_settings.json" + try: + return json.loads(settings_file.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return {} + + def get_auth_token(x_hub_token: str = Header(None)) -> bool: - config = load_router_config() - server_host = config.hub.get('web_api_host', '127.0.0.1') + settings = _web_settings() + server_host = settings.get('web_api_host', '127.0.0.1') if server_host != '127.0.0.1': - required_token = config.hub.get('web_api_token', '') + required_token = settings.get('web_api_token', '') if not required_token: raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token") if x_hub_token != required_token: @@ -42,7 +61,7 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool: def health_check(): return { "ok": True, - "version": "1.0.0", + "version": __version__, "auth_flows": { "openai-codex": {"supported": True, "reason": "device-code"}, "grok": {"supported": True, "reason": "device-code"}, @@ -101,10 +120,10 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to def run_server(): import uvicorn - config = load_router_config() - host = config.hub.get('web_api_host', '127.0.0.1') - port = int(config.hub.get('web_api_port', 5800)) - token = config.hub.get('web_api_token', '') + settings = _web_settings() + host = settings.get('web_api_host', '127.0.0.1') + port = int(settings.get('web_api_port', 5800)) + token = settings.get('web_api_token', '') if host != '127.0.0.1' and not token: logger.error("Cannot bind Web API externally without web_api_token") diff --git a/tests/test_ui_claude_grok_connection.py b/tests/test_ui_claude_grok_connection.py index 17aa771..c9e5514 100644 --- a/tests/test_ui_claude_grok_connection.py +++ b/tests/test_ui_claude_grok_connection.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router import action_handler from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing from antigravity_provider.router.hermes_hub_app import do_test_profile @@ -83,10 +84,10 @@ def test_do_test_profile_for_grok_and_claude(): mock_adapter = MagicMock() mock_adapter.health_check.return_value = True - with patch("antigravity_provider.router.hermes_hub_app.load_router_config", return_value=config), \ + with patch("antigravity_provider.router.action_handler.load_router_config", return_value=config), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \ - patch("antigravity_provider.router.hermes_hub_app.get_adapter", return_value=mock_adapter): + patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter): res_grok = do_test_profile("grok", "grok-worker-1") assert res_grok["success"] is True diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py index c692a46..6be7af2 100644 --- a/tests/test_ui_routing_graph.py +++ b/tests/test_ui_routing_graph.py @@ -8,6 +8,7 @@ import pytest pytest.importorskip("customtkinter") from antigravity_provider.router.ui import routing_graph as graph_module +from antigravity_provider.router import action_handler 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 @@ -164,13 +165,13 @@ def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch): 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)) + monkeypatch.setattr(action_handler, "load_router_config", lambda: config) + monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True}) + monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"}) + monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter()) + monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None)) - result = app_module.do_test_profile("antigravity", "connected") + result = action_handler.do_test_profile("antigravity", "connected") assert result["success"] is True assert "runtime" in result["response"]