"""Hermes Hub — Add Account Multi-Step Wizard Modal.""" from __future__ import annotations import json import os import threading import time import webbrowser from typing import Any, Callable, Dict, List, Optional import customtkinter as ctk from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.components import HubButton, HubCard, HubModal from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.unified_health import EventLogService class AddAccountWizard(HubModal): """4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment.""" def __init__(self, parent: Any, on_complete: Optional[Callable[[Dict[str, Any]], None]] = None): super().__init__(parent, title="Мастер подключения аккаунта", width=620, height=520) self.on_complete = on_complete self.step = 1 self.selected_provider: str = "antigravity" self.target_slot: str = "" self.discovered_identity: str = "" self.discovered_models: List[str] = [] self.is_verified: bool = False self.oauth_session_id: Optional[str] = None self.oauth_url: Optional[str] = None self._polling_active = False self._show_step_1_provider() def _clear_body(self): for w in self.body.winfo_children(): w.destroy() for w in self.footer.winfo_children(): w.destroy() # ═══════════════════════════════════════════════════════════════ # STEP 1: Provider Selection # ═══════════════════════════════════════════════════════════════ def _show_step_1_provider(self): self._clear_body() self.title_lbl.configure(text="Шаг 1 из 4: Выберите провайдера ИИ") ctk.CTkLabel( self.body, text="Выберите платформу для подключения учетной записи:", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY, ).pack(anchor="w", pady=(0, 12)) prov_var = ctk.StringVar(value=self.selected_provider) providers = [ ("antigravity", "Google Antigravity", "OAuth 2.0 • Gemini 2.5 Pro, Gemini 2.5 Flash, Claude Sonnet", Theme.PROVIDER_ANTIGRAVITY), ("openai-codex", "OpenAI Codex", "API Key • GPT-5.3 Codex, GPT-5.1 Codex Mini", Theme.PROVIDER_CODEX), ("opencode-go", "OpenCode Go", "Bearer API Key • OpenCode Go 3", Theme.PROVIDER_OPENCODE), ] for p_id, p_name, p_desc, p_col in providers: card = HubCard(self.body, border_color=Theme.BORDER, fg_color=Theme.SURFACE) card.pack(fill="x", pady=6) rb = ctk.CTkRadioButton( card, text=p_name, variable=prov_var, value=p_id, font=Theme.font_heading(), text_color=p_col, fg_color=Theme.ACCENT, hover_color=Theme.ACCENT_HOVER, ) rb.pack(anchor="w", padx=16, pady=(10, 2)) ctk.CTkLabel( card, text=p_desc, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED, ).pack(anchor="w", padx=44, pady=(0, 10)) def _next(): self.selected_provider = prov_var.get() self._show_step_2_auth() HubButton(self.footer, text="Отмена", variant="secondary", width=100, command=self.destroy).pack(side="left") HubButton(self.footer, text="Далее ➔", variant="primary", width=120, command=_next).pack(side="right") # ═══════════════════════════════════════════════════════════════ # STEP 2: Authentication # ═══════════════════════════════════════════════════════════════ def _show_step_2_auth(self): self._clear_body() self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи") # Find target slot self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or "ag-spare-1" if self.selected_provider == "antigravity": self._build_antigravity_oauth_flow() else: self._build_api_key_flow() def _build_antigravity_oauth_flow(self): ctk.CTkLabel( self.body, text="Для Google Antigravity требуется вход через защищённый протокол Google OAuth:", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY, wraplength=540, justify="left", ).pack(anchor="w", pady=(0, 10)) steps_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) steps_card.pack(fill="x", pady=(0, 12)) instructions = ( "1. Нажмите «Открыть браузер» для перехода на страницу Google.\n" "2. Войдите в нужный Google аккаунт и предоставьте доступ.\n" "3. Hermes Hub автоматически перехватит токен и завершит подключение." ) ctk.CTkLabel( steps_card, text=instructions, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY, justify="left", ).pack(padx=14, pady=12, anchor="w") self.oauth_status_lbl = ctk.CTkLabel( self.body, text="Статус: Ожидание запуска OAuth...", font=Theme.font_body_bold(), text_color=Theme.STATUS_WARNING, ) self.oauth_status_lbl.pack(pady=8) btns_row = ctk.CTkFrame(self.body, fg_color="transparent") btns_row.pack(fill="x", pady=4) HubButton( btns_row, text="🌐 Открыть браузер", variant="primary", width=180, command=self._start_antigravity_oauth, ).pack(side="left", padx=(0, 8)) HubButton( btns_row, text="📋 Копировать ссылку", variant="secondary", width=160, command=self._copy_oauth_url, ).pack(side="left") HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left") def _build_api_key_flow(self): ctk.CTkLabel( self.body, text=f"Введите ключ API для {self.selected_provider}:", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY, ).pack(anchor="w", pady=(0, 6)) self.key_entry = ctk.CTkEntry( self.body, placeholder_text="sk-...", font=Theme.font_mono(), show="*", height=38, fg_color=Theme.PRIMARY, border_color=Theme.BORDER, text_color=Theme.TEXT_PRIMARY, ) self.key_entry.pack(fill="x", pady=(0, 8)) self.key_status_lbl = ctk.CTkLabel( self.body, text="", font=Theme.font_caption(), text_color=Theme.STATUS_ERROR, ) self.key_status_lbl.pack(anchor="w") def _save_key(): k = self.key_entry.get().strip() if not k: self.key_status_lbl.configure(text="Пожалуйста, введите ключ API.") return # Perform real key verification is_valid = False masked_id = None models: List[str] = [] if self.selected_provider == "openai-codex": is_valid, masked_id, models = ProfileAuthManager.verify_codex_token(k) elif self.selected_provider == "opencode-go": is_valid, masked_id, models = ProfileAuthManager.verify_opencode_token(k) # Save auth data safely auth_data = { "provider": self.selected_provider, "profile_id": self.target_slot, "api_key": k, "created_at": time.time(), } ProfileAuthManager.save_profile_auth(self.selected_provider, self.target_slot, auth_data) self.is_verified = is_valid self.discovered_identity = masked_id or (k[:8] + "..." + k[-4:] if len(k) > 12 else "API Key") self.discovered_models = models if is_valid else [] self._show_step_3_validation() HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left") HubButton(self.footer, text="Проверить и сохранить ➔", variant="primary", width=200, command=_save_key).pack(side="right") def _start_antigravity_oauth(self): self.oauth_status_lbl.configure(text="Запуск локального слушателя и открытие Google...") try: from antigravity_provider.router.profile_oauth import start_profile_oauth self.oauth_session_id, self.oauth_url = start_profile_oauth(self.target_slot) webbrowser.open(self.oauth_url) self.oauth_status_lbl.configure(text="🌐 Ожидание авторизации в браузере...") self._polling_active = True threading.Thread(target=self._poll_oauth, daemon=True).start() except Exception as e: self.oauth_status_lbl.configure(text=f"Ошибка: {e}") def _copy_oauth_url(self): if self.oauth_url: self.clipboard_clear() self.clipboard_append(self.oauth_url) self.oauth_status_lbl.configure(text="✅ Ссылка скопирована в буфер обмена!") def _poll_oauth(self): from antigravity_provider.router.profile_oauth import get_oauth_session for _ in range(120): if not self._polling_active: return time.sleep(1) session = get_oauth_session(self.oauth_session_id) if not session: continue status = getattr(session, "status", "").lower() if status in ("completed", "success"): info = getattr(session, "completed_profile_info", {}) or {} self.discovered_identity = info.get("email") or "Google Account" self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"] self.is_verified = True self.after(0, self._show_step_3_validation) return elif status in ("error", "failed", "cancelled"): err_msg = getattr(session, "error_msg", None) or "Авторизация отменена или не удалась" self.after(0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}")) return elif status == "timeout": self.after(0, lambda: self.oauth_status_lbl.configure(text="❌ Время ожидания авторизации истекло")) return # ═══════════════════════════════════════════════════════════════ # STEP 3: Validation & Duplicate Detection # ═══════════════════════════════════════════════════════════════ def _show_step_3_validation(self): self._clear_body() self._polling_active = False self.title_lbl.configure(text="Шаг 3 из 4: Валидация аккаунта") # Check duplicate dup_pid = AutoAssigner.check_duplicate_identity(self.selected_provider, self.discovered_identity, exclude_profile_id=self.target_slot) if dup_pid: dname, _, _ = AutoAssigner.get_display_name_and_role(dup_pid) warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color="#3B2610") warn_card.pack(fill="x", pady=(0, 12)) ctk.CTkLabel( warn_card, text=f"⚠️ Внимание: Этот аккаунт ({self.discovered_identity}) уже подключён к слоту «{dname}» ({dup_pid}).", font=Theme.font_body_bold(), text_color=Theme.STATUS_WARNING, wraplength=500, justify="left", ).pack(padx=14, pady=10) # Status card status_color = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING status_text = f"✓ Аккаунт успешно проверен: {self.discovered_identity}" if self.is_verified else f"⚠ Аккаунт сохранён (НЕ ПРОВЕРЕН): {self.discovered_identity}" succ_card = HubCard(self.body, border_color=status_color) succ_card.pack(fill="x", pady=6) ctk.CTkLabel( succ_card, text=status_text, font=Theme.font_heading(), text_color=status_color, ).pack(anchor="w", padx=16, pady=(12, 4)) ctk.CTkLabel( succ_card, text=f"Провайдер: {self.selected_provider} | Слот: {self.target_slot}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, ).pack(anchor="w", padx=16, pady=(0, 12)) # Models list if self.discovered_models: ctk.CTkLabel(self.body, text="Доступные проверенные модели:", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", pady=(8, 4)) for m in self.discovered_models: ctk.CTkLabel(self.body, text=f" ✓ {m}", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w") else: ctk.CTkLabel(self.body, text="Модели не обнаружены или не проверены.", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="w", pady=(8, 4)) HubButton(self.footer, text="Перейти к назначению роли ➔", variant="primary", width=220, command=self._show_step_4_assignment).pack(side="right") # ═══════════════════════════════════════════════════════════════ # STEP 4: Auto-Assignment Recommendation # ═══════════════════════════════════════════════════════════════ def _show_step_4_assignment(self): self._clear_body() self.title_lbl.configure(text="Шаг 4 из 4: Назначение роли в команде") # Get AutoAssigner recommendation rec_slot, rec_title, rec_reason = AutoAssigner.recommend_assignment(self.selected_provider) rec_card = HubCard(self.body, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK) rec_card.pack(fill="x", pady=(0, 14)) ctk.CTkLabel( rec_card, text=f"⚡ Рекомендация Hermes Hub: «{rec_title}»", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT, ).pack(anchor="w", padx=14, pady=(10, 2)) ctk.CTkLabel( rec_card, text=rec_reason, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, wraplength=500, justify="left", ).pack(anchor="w", padx=14, pady=(0, 10)) # Options role_var = ctk.StringVar(value="auto") roles_opts = [ ("auto", f"Автоматически: {rec_title} (Рекомендуется)"), ("orchestrator", "Главный оркестратор"), ("coder", "Кодер"), ("reviewer", "Ревьюер"), ("researcher", "Исследователь"), ("spare", "Только резерв (Spare)"), ] for val, lbl in roles_opts: ctk.CTkRadioButton( self.body, text=lbl, variable=role_var, value=val, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY, fg_color=Theme.ACCENT, hover_color=Theme.ACCENT_HOVER, ).pack(anchor="w", padx=8, pady=3) def _finish(): chosen = role_var.get() target_role = "orchestrator" if chosen == "orchestrator" else ( "coder" if chosen == "coder" else ( "reviewer" if chosen == "reviewer" else ( "researcher" if chosen == "researcher" else ( "spare" if chosen == "spare" else "general" ) ) ) ) # Apply role to live config AutoAssigner.assign_profile_to_role(self.target_slot, target_role, is_primary=(chosen != "spare")) EventLogService.get().log( "account", f"Подключён аккаунт {self.discovered_identity} ({self.selected_provider}). Назначен на роль: {target_role}.", level="success", ) self.destroy() if self.on_complete: self.on_complete({ "provider": self.selected_provider, "slot": self.target_slot, "identity": self.discovered_identity, "role": target_role, }) HubButton(self.footer, text="Готово (Завершить)", variant="primary", width=180, command=_finish).pack(side="right")