1750 lines
70 KiB
Python
1750 lines
70 KiB
Python
"""Hermes Hub — Add Account Multi-Step Wizard Modal (v4).
|
||
|
||
Supports 5 AI Providers:
|
||
1. Google Antigravity (Cockpit Tools model OAuth with immediate URL & manual fallback)
|
||
2. OpenAI Codex (OAuth / ChatGPT Device Code flow + OpenAI API Key mode)
|
||
3. OpenCode Go (API Key / Token input with clipboard Paste button & full keyboard shortcuts)
|
||
4. Claude / Anthropic (OAuth PKCE for Claude Pro/Max + Anthropic API Key mode)
|
||
5. Grok / xAI (OAuth Device Code for SuperGrok + xAI API Key mode)
|
||
|
||
Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles.
|
||
"""
|
||
|
||
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, HubEntry, HubModal
|
||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||
from antigravity_provider.router.router_config import load_router_config
|
||
from antigravity_provider.router.unified_health import EventLogService
|
||
|
||
|
||
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
|
||
"""Keep existing chain rank or route a newly introduced profile slot."""
|
||
config = load_router_config()
|
||
assigned_role = next(
|
||
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
|
||
"",
|
||
)
|
||
if assigned_role:
|
||
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
|
||
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
|
||
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
|
||
|
||
|
||
class AddAccountWizard(HubModal):
|
||
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""
|
||
|
||
def __init__(self, parent: Any, on_complete: Optional[Callable[[Dict[str, Any]], None]] = None):
|
||
super().__init__(parent, title="Мастер подключения аккаунта", width=640, height=560)
|
||
self.on_complete = on_complete
|
||
self.step = 1
|
||
|
||
self.selected_provider: str = "antigravity"
|
||
self.codex_auth_mode: str = "oauth" # oauth | api_key
|
||
self.claude_auth_mode: str = "oauth" # oauth | api_key
|
||
self.grok_auth_mode: str = "oauth" # oauth | api_key
|
||
|
||
self.target_slot: str = ""
|
||
self.discovered_identity: str = ""
|
||
self.discovered_plan: str = "Тариф: неизвестен"
|
||
self.discovered_models: List[str] = []
|
||
self.is_verified: bool = False
|
||
|
||
# Session tracking
|
||
self.oauth_session_id: Optional[str] = None
|
||
self.oauth_url: Optional[str] = None
|
||
self.oauth_port: Optional[int] = None
|
||
|
||
self.codex_session_id: Optional[str] = None
|
||
self.codex_url: Optional[str] = None
|
||
self.codex_user_code: Optional[str] = None
|
||
|
||
self.claude_session_id: Optional[str] = None
|
||
self.claude_url: Optional[str] = None
|
||
|
||
self.grok_session_id: Optional[str] = None
|
||
self.grok_url: Optional[str] = None
|
||
self.grok_user_code: Optional[str] = None
|
||
|
||
self._polling_active = False
|
||
|
||
self._show_step_1_provider()
|
||
|
||
def destroy(self):
|
||
self._polling_active = False
|
||
self._cancel_active_sessions()
|
||
super().destroy()
|
||
|
||
def _clear_body(self):
|
||
for w in self.body.winfo_children():
|
||
w.destroy()
|
||
for w in self.footer.winfo_children():
|
||
w.destroy()
|
||
|
||
def _cancel_active_sessions(self):
|
||
if self.oauth_session_id:
|
||
try:
|
||
from antigravity_provider.router.profile_oauth import cancel_oauth_session
|
||
|
||
cancel_oauth_session(self.oauth_session_id)
|
||
except Exception:
|
||
pass
|
||
self.oauth_session_id = None
|
||
self.oauth_port = None
|
||
|
||
if self.codex_session_id:
|
||
try:
|
||
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
|
||
|
||
cancel_codex_oauth_session(self.codex_session_id)
|
||
except Exception:
|
||
pass
|
||
self.codex_session_id = None
|
||
|
||
if self.claude_session_id:
|
||
try:
|
||
from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session
|
||
|
||
cancel_claude_oauth_session(self.claude_session_id)
|
||
except Exception:
|
||
pass
|
||
self.claude_session_id = None
|
||
|
||
if self.grok_session_id:
|
||
try:
|
||
from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session
|
||
|
||
cancel_grok_oauth_session(self.grok_session_id)
|
||
except Exception:
|
||
pass
|
||
self.grok_session_id = None
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# STEP 1: Provider Selection
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _show_step_1_provider(self):
|
||
self._cancel_active_sessions()
|
||
self._polling_active = False
|
||
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, 10))
|
||
|
||
providers = [
|
||
("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT),
|
||
("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", Theme.PROVIDER_CODEX),
|
||
("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE),
|
||
("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK),
|
||
("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC),
|
||
("local", "Локальная модель (Local LLM / llama.cpp)", "Локальный сервер / Ollama / vLLM / llama.cpp", Theme.STATUS_HEALTHY),
|
||
]
|
||
|
||
self.provider_var = ctk.StringVar(value=self.selected_provider)
|
||
|
||
for p_id, p_title, p_desc, p_color in providers:
|
||
card = HubCard(self.body)
|
||
card.pack(fill="x", pady=3)
|
||
|
||
r_frame = ctk.CTkFrame(card, fg_color="transparent")
|
||
r_frame.pack(fill="x", padx=12, pady=6)
|
||
|
||
rb = ctk.CTkRadioButton(
|
||
r_frame,
|
||
text=p_title,
|
||
value=p_id,
|
||
variable=self.provider_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=p_color,
|
||
command=lambda pid=p_id: self._select_provider(pid),
|
||
)
|
||
rb.pack(side="left")
|
||
|
||
ctk.CTkLabel(
|
||
r_frame,
|
||
text=f"• {p_desc}",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.TEXT_MUTED,
|
||
).pack(side="left", padx=(10, 0))
|
||
|
||
# Footer
|
||
HubButton(
|
||
self.footer,
|
||
text="Отмена",
|
||
variant="ghost",
|
||
command=self.destroy,
|
||
).pack(side="left", padx=10, pady=10)
|
||
|
||
HubButton(
|
||
self.footer,
|
||
text="Далее →",
|
||
variant="primary",
|
||
command=self._proceed_to_step_2,
|
||
).pack(side="right", padx=10, pady=10)
|
||
|
||
def _select_provider(self, p_id: str):
|
||
self.selected_provider = p_id
|
||
|
||
def _proceed_to_step_2(self):
|
||
self.selected_provider = self.provider_var.get()
|
||
self.step = 2
|
||
self._show_step_2_auth()
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# STEP 2: Authentication
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _show_step_2_auth(self):
|
||
self._clear_body()
|
||
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
|
||
|
||
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()
|
||
elif self.selected_provider == "openai-codex":
|
||
if self.codex_auth_mode == "oauth":
|
||
self._build_codex_oauth_flow()
|
||
else:
|
||
self._build_api_key_flow()
|
||
elif self.selected_provider == "claude":
|
||
if self.claude_auth_mode == "oauth":
|
||
self._build_claude_oauth_flow()
|
||
else:
|
||
self._build_api_key_flow()
|
||
elif self.selected_provider == "grok":
|
||
if self.grok_auth_mode == "oauth":
|
||
self._build_grok_oauth_flow()
|
||
else:
|
||
self._build_api_key_flow()
|
||
elif self.selected_provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
||
self._build_local_llm_flow()
|
||
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",
|
||
"local": "Local LLM",
|
||
}
|
||
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 (Native agy CLI login)
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_antigravity_oauth_flow(self):
|
||
disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot)
|
||
|
||
ctk.CTkLabel(
|
||
self.body,
|
||
text=f"Подключение Google Antigravity к слоту: {self.target_slot} ({disp_name})",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", pady=(0, 4))
|
||
|
||
info_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||
info_card.pack(fill="x", pady=(0, 8))
|
||
|
||
ctk.CTkLabel(
|
||
info_card,
|
||
text="Родной вход через CLI agy в изолированном окружении профиля:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", padx=12, pady=(10, 4))
|
||
|
||
ctk.CTkLabel(
|
||
info_card,
|
||
text=(
|
||
"1. Нажмите «Открыть терминал входа Antigravity (agy)» ниже.\n"
|
||
"2. Откроется видимое окно терминала agy и запустит вход Google в браузере.\n"
|
||
"3. Войдите в нужный Google-аккаунт в открывшемся браузере.\n"
|
||
"4. Hub автоматически обнаружит успешный вход и перейдет к следующему шагу."
|
||
),
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
justify="left",
|
||
anchor="w",
|
||
).pack(fill="x", padx=12, pady=(0, 10))
|
||
|
||
btn_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
||
btn_row.pack(fill="x", pady=(0, 6))
|
||
|
||
self.launch_agy_btn = HubButton(
|
||
btn_row,
|
||
text="🚀 Открыть терминал входа Antigravity (agy)",
|
||
variant="primary",
|
||
height=40,
|
||
command=self._launch_agy_login,
|
||
)
|
||
self.launch_agy_btn.pack(side="left", fill="x", expand=True)
|
||
|
||
self.oauth_status_lbl = ctk.CTkLabel(
|
||
self.body,
|
||
text="Нажмите кнопку выше для открытия окна входа...",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
)
|
||
self.oauth_status_lbl.pack(fill="x", pady=6)
|
||
|
||
self._build_step_2_footer()
|
||
self._check_initial_native_status()
|
||
|
||
def _launch_agy_login(self):
|
||
try:
|
||
from antigravity_provider.agy_subprocess import launch_native_agy_login
|
||
|
||
self.agy_proc = launch_native_agy_login(self.target_slot)
|
||
self.oauth_status_lbl.configure(
|
||
text="Окно agy открыто. Завершите вход в браузере...",
|
||
text_color=Theme.ACCENT,
|
||
)
|
||
self._polling_active = True
|
||
threading.Thread(target=self._poll_native_agy_auth, daemon=True).start()
|
||
except Exception as e:
|
||
self.oauth_status_lbl.configure(
|
||
text=f"Ошибка запуска agy: {e}",
|
||
text_color=Theme.STATUS_ERROR,
|
||
)
|
||
|
||
def _check_initial_native_status(self):
|
||
from antigravity_provider.agy_subprocess import check_profile_native_auth_status
|
||
|
||
is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot)
|
||
if is_authed:
|
||
self.discovered_identity = email or "Google Account"
|
||
self.discovered_plan = "PRO"
|
||
self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"]
|
||
self.is_verified = True
|
||
self._show_step_3_validation()
|
||
|
||
def _poll_native_agy_auth(self):
|
||
from antigravity_provider.agy_subprocess import check_profile_native_auth_status
|
||
|
||
for _ in range(300):
|
||
if not self._polling_active:
|
||
return
|
||
time.sleep(1)
|
||
is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot)
|
||
if is_authed:
|
||
self.discovered_identity = email or "Google Account"
|
||
self.discovered_plan = "PRO"
|
||
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
|
||
|
||
if getattr(self, "agy_proc", None) and self.agy_proc.poll() is not None:
|
||
# Process exited, check one last time
|
||
is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot)
|
||
if is_authed:
|
||
self.discovered_identity = email or "Google Account"
|
||
self.discovered_plan = "PRO"
|
||
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
|
||
else:
|
||
self.after(
|
||
0,
|
||
lambda: self.oauth_status_lbl.configure(
|
||
text="⚠️ Окно agy было закрыто до завершения авторизации. Нажмите кнопку, чтобы попробовать снова.",
|
||
text_color=Theme.STATUS_WARNING,
|
||
),
|
||
)
|
||
return
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# OPENAI CODEX OAUTH FLOW
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_codex_oauth_flow(self):
|
||
mode_card = HubCard(self.body, fg_color="transparent")
|
||
mode_card.pack(fill="x", pady=(0, 8))
|
||
|
||
self.codex_mode_var = ctk.StringVar(value="oauth")
|
||
|
||
rb1 = ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="OAuth — OpenAI / ChatGPT аккаунт (Рекомендуется)",
|
||
value="oauth",
|
||
variable=self.codex_mode_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=Theme.ACCENT,
|
||
command=self._on_codex_mode_toggle,
|
||
)
|
||
rb1.pack(anchor="w", padx=4, pady=2)
|
||
|
||
rb2 = ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="API Key — OpenAI API (sk-...)",
|
||
value="api_key",
|
||
variable=self.codex_mode_var,
|
||
font=Theme.font_body(),
|
||
fg_color=Theme.ACCENT,
|
||
command=self._on_codex_mode_toggle,
|
||
)
|
||
rb2.pack(anchor="w", padx=4, pady=2)
|
||
|
||
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||
auth_card.pack(fill="x", pady=(0, 8))
|
||
|
||
ctk.CTkLabel(
|
||
auth_card,
|
||
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))
|
||
|
||
self.codex_url_entry = HubEntry(
|
||
url_row,
|
||
font=Theme.font_mono(),
|
||
height=32,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.codex_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
HubButton(
|
||
url_row,
|
||
text="Копировать ссылку",
|
||
variant="secondary",
|
||
width=130,
|
||
height=32,
|
||
command=self._copy_codex_url,
|
||
).pack(side="right")
|
||
|
||
code_card = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||
code_card.pack(fill="x", padx=10, pady=(0, 6))
|
||
|
||
ctk.CTkLabel(
|
||
code_card,
|
||
text="2. Введите на странице код:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
).pack(side="left", padx=(0, 8))
|
||
|
||
self.codex_code_lbl = ctk.CTkLabel(
|
||
code_card,
|
||
text="...",
|
||
font=("Consolas", 18, "bold"),
|
||
text_color=Theme.ACCENT,
|
||
)
|
||
self.codex_code_lbl.pack(side="left", padx=(0, 8))
|
||
|
||
HubButton(
|
||
code_card,
|
||
text="📋 Копировать код",
|
||
variant="secondary",
|
||
width=130,
|
||
height=28,
|
||
command=self._copy_codex_code,
|
||
).pack(side="left")
|
||
|
||
action_row = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||
action_row.pack(fill="x", padx=10, pady=(0, 8))
|
||
|
||
HubButton(
|
||
action_row,
|
||
text="🌐 Открыть в браузере",
|
||
variant="primary",
|
||
width=180,
|
||
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))
|
||
|
||
ctk.CTkLabel(
|
||
manual_card,
|
||
text="▸ Вставить токен вручную:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", padx=10, pady=(6, 2))
|
||
|
||
manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent")
|
||
manual_entry_row.pack(fill="x", padx=10, pady=(0, 6))
|
||
|
||
self.codex_manual_entry = HubEntry(
|
||
manual_entry_row,
|
||
placeholder_text='{"access_token": "..."} или токен...',
|
||
font=Theme.font_mono(),
|
||
height=32,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.codex_manual_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
HubButton(
|
||
manual_entry_row,
|
||
text="📋 Вставить",
|
||
variant="secondary",
|
||
width=80,
|
||
height=32,
|
||
command=lambda: self._paste_into_entry(self.codex_manual_entry),
|
||
).pack(side="right")
|
||
|
||
HubButton(
|
||
manual_card,
|
||
text="✓ Завершить авторизацию",
|
||
variant="secondary",
|
||
height=30,
|
||
command=self._handle_codex_manual_submit,
|
||
).pack(anchor="w", padx=10, pady=(0, 6))
|
||
|
||
self.codex_status_lbl = ctk.CTkLabel(
|
||
self.body,
|
||
text="Создание сессии входа OpenAI...",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
)
|
||
self.codex_status_lbl.pack(fill="x", pady=4)
|
||
|
||
self._build_step_2_footer()
|
||
self._init_codex_oauth()
|
||
|
||
def _on_codex_mode_toggle(self):
|
||
self.codex_auth_mode = self.codex_mode_var.get()
|
||
self._show_step_2_auth()
|
||
|
||
def _init_codex_oauth(self):
|
||
try:
|
||
from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session
|
||
|
||
session_id, url, code = start_codex_oauth(self.target_slot)
|
||
self.codex_session_id = session_id
|
||
self.codex_url = url
|
||
self.codex_user_code = code
|
||
|
||
session = get_codex_oauth_session(session_id)
|
||
if not code or (session and session.status == "failed"):
|
||
err_msg = getattr(session, "error_msg", None) or "Не удалось получить код авторизации"
|
||
self.codex_status_lbl.configure(text=f"❌ {err_msg}", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
self.codex_url_entry.delete(0, "end")
|
||
self.codex_url_entry.insert(0, url)
|
||
self.codex_code_lbl.configure(text=code)
|
||
|
||
if getattr(session, "is_dev_mode", False):
|
||
self.codex_status_lbl.configure(
|
||
text=f"⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE): Код {code}",
|
||
text_color=Theme.STATUS_WARNING,
|
||
)
|
||
else:
|
||
self.codex_status_lbl.configure(
|
||
text=f"Ожидание подтверждения кода {code} в браузере...",
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
)
|
||
|
||
self._polling_active = True
|
||
threading.Thread(target=self._poll_codex_oauth, daemon=True).start()
|
||
except Exception as e:
|
||
self.codex_status_lbl.configure(text=f"Ошибка: {e}", text_color=Theme.STATUS_ERROR)
|
||
|
||
def _copy_codex_url(self):
|
||
if self.codex_url:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(self.codex_url)
|
||
|
||
def _copy_codex_code(self):
|
||
if self.codex_user_code:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(self.codex_user_code)
|
||
|
||
def _open_codex_browser(self):
|
||
if self.codex_url:
|
||
self._copy_codex_code()
|
||
self.codex_status_lbl.configure(
|
||
text=(
|
||
"Код скопирован. Вставьте его на открывшейся странице OpenAI; "
|
||
"затем вернитесь сюда — Hub продолжит автоматически."
|
||
),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
)
|
||
webbrowser.open(self.codex_url)
|
||
|
||
def _handle_codex_manual_submit(self):
|
||
raw = self.codex_manual_entry.get().strip()
|
||
if not raw:
|
||
self.codex_status_lbl.configure(text="❌ Введите данные токена", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
|
||
|
||
session = get_codex_oauth_session(self.codex_session_id)
|
||
if not session:
|
||
self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
ok, msg = session.handle_manual_input(raw)
|
||
if ok:
|
||
info = getattr(session, "completed_profile_info", {}) or {}
|
||
self.discovered_identity = info.get("email") or "ChatGPT Account"
|
||
self.discovered_plan = "PLUS"
|
||
self.discovered_models = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
|
||
self.is_verified = True
|
||
self._show_step_3_validation()
|
||
else:
|
||
self.codex_status_lbl.configure(text=f"❌ {msg}", text_color=Theme.STATUS_ERROR)
|
||
|
||
def _poll_codex_oauth(self):
|
||
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
|
||
|
||
for _ in range(900):
|
||
if not self._polling_active:
|
||
return
|
||
time.sleep(1)
|
||
session = get_codex_oauth_session(self.codex_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 "ChatGPT Account"
|
||
self.discovered_plan = "PLUS"
|
||
self.discovered_models = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
|
||
self.is_verified = True
|
||
self.after(0, self._show_step_3_validation)
|
||
return
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# CLAUDE (ANTHROPIC) OAUTH FLOW
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_claude_oauth_flow(self):
|
||
mode_card = HubCard(self.body, fg_color="transparent")
|
||
mode_card.pack(fill="x", pady=(0, 8))
|
||
|
||
self.claude_mode_var = ctk.StringVar(value="oauth")
|
||
|
||
rb1 = ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="OAuth — Claude Pro/Max Account (Рекомендуется)",
|
||
value="oauth",
|
||
variable=self.claude_mode_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=Theme.PROVIDER_CLAUDE,
|
||
command=self._on_claude_mode_toggle,
|
||
)
|
||
rb1.pack(anchor="w", padx=4, pady=2)
|
||
|
||
rb2 = ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="API Key — Anthropic API (sk-ant-...)",
|
||
value="api_key",
|
||
variable=self.claude_mode_var,
|
||
font=Theme.font_body(),
|
||
fg_color=Theme.PROVIDER_CLAUDE,
|
||
command=self._on_claude_mode_toggle,
|
||
)
|
||
rb2.pack(anchor="w", padx=4, pady=2)
|
||
|
||
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||
auth_card.pack(fill="x", pady=(0, 8))
|
||
|
||
ctk.CTkLabel(
|
||
auth_card,
|
||
text="Ссылка для входа в Claude:",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
).pack(anchor="w", padx=10, pady=(6, 2))
|
||
|
||
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||
url_row.pack(fill="x", padx=10, pady=(0, 6))
|
||
|
||
self.claude_url_entry = HubEntry(
|
||
url_row,
|
||
font=Theme.font_mono(),
|
||
height=32,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.claude_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
HubButton(
|
||
url_row,
|
||
text="Копировать ссылку",
|
||
variant="secondary",
|
||
width=130,
|
||
height=32,
|
||
command=self._copy_claude_url,
|
||
).pack(side="right")
|
||
|
||
action_row = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||
action_row.pack(fill="x", padx=10, pady=(0, 8))
|
||
|
||
HubButton(
|
||
action_row,
|
||
text="🌐 Открыть в браузере",
|
||
variant="primary",
|
||
width=180,
|
||
command=self._open_claude_browser,
|
||
).pack(side="left", padx=(0, 8))
|
||
|
||
# Code Entry Card
|
||
code_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||
code_card.pack(fill="x", pady=(0, 8))
|
||
|
||
ctk.CTkLabel(
|
||
code_card,
|
||
text="Вставьте код авторизации после входа на сайте Claude:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", padx=10, pady=(6, 2))
|
||
|
||
code_row = ctk.CTkFrame(code_card, fg_color="transparent")
|
||
code_row.pack(fill="x", padx=10, pady=(0, 6))
|
||
|
||
self.claude_code_entry = HubEntry(
|
||
code_row,
|
||
placeholder_text="Вставьте код...",
|
||
font=Theme.font_mono(),
|
||
height=32,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.claude_code_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
HubButton(
|
||
code_row,
|
||
text="📋 Вставить",
|
||
variant="secondary",
|
||
width=80,
|
||
height=32,
|
||
command=lambda: self._paste_into_entry(self.claude_code_entry),
|
||
).pack(side="right")
|
||
|
||
HubButton(
|
||
code_card,
|
||
text="✓ Завершить авторизацию",
|
||
variant="secondary",
|
||
height=30,
|
||
command=self._handle_claude_code_submit,
|
||
).pack(anchor="w", padx=10, pady=(0, 6))
|
||
|
||
self.claude_status_lbl = ctk.CTkLabel(
|
||
self.body,
|
||
text="Создание ссылки входа Claude...",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
)
|
||
self.claude_status_lbl.pack(fill="x", pady=4)
|
||
|
||
self._build_step_2_footer()
|
||
self._init_claude_oauth()
|
||
|
||
def _on_claude_mode_toggle(self):
|
||
self.claude_auth_mode = self.claude_mode_var.get()
|
||
self._show_step_2_auth()
|
||
|
||
def _init_claude_oauth(self):
|
||
try:
|
||
from antigravity_provider.router.claude_oauth import start_claude_oauth
|
||
|
||
session_id, url = start_claude_oauth(self.target_slot)
|
||
self.claude_session_id = session_id
|
||
self.claude_url = url
|
||
|
||
self.claude_url_entry.delete(0, "end")
|
||
self.claude_url_entry.insert(0, url)
|
||
self.claude_status_lbl.configure(text="Перейдите по ссылке и скопируйте код авторизации.")
|
||
except Exception as e:
|
||
self.claude_status_lbl.configure(text=f"Ошибка: {e}", text_color=Theme.STATUS_ERROR)
|
||
|
||
def _copy_claude_url(self):
|
||
if self.claude_url:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(self.claude_url)
|
||
|
||
def _open_claude_browser(self):
|
||
if self.claude_url:
|
||
webbrowser.open(self.claude_url)
|
||
|
||
def _handle_claude_code_submit(self):
|
||
raw = self.claude_code_entry.get().strip()
|
||
if not raw:
|
||
self.claude_status_lbl.configure(text="❌ Вставьте код авторизации", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
from antigravity_provider.router.claude_oauth import get_claude_oauth_session
|
||
|
||
session = get_claude_oauth_session(self.claude_session_id)
|
||
if not session:
|
||
self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
ok, msg = session.handle_auth_code(raw)
|
||
if ok:
|
||
info = getattr(session, "completed_profile_info", {}) or {}
|
||
self.discovered_identity = info.get("email") or "Claude Account"
|
||
self.discovered_plan = "MAX"
|
||
self.discovered_models = ["claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus"]
|
||
self.is_verified = True
|
||
self._show_step_3_validation()
|
||
else:
|
||
self.claude_status_lbl.configure(text=f"❌ {msg}", text_color=Theme.STATUS_ERROR)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# GROK (XAI) OAUTH FLOW
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_grok_oauth_flow(self):
|
||
mode_card = HubCard(self.body, fg_color="transparent")
|
||
mode_card.pack(fill="x", pady=(0, 8))
|
||
|
||
self.grok_mode_var = ctk.StringVar(value="oauth")
|
||
|
||
rb1 = ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="OAuth — xAI / SuperGrok Account (Рекомендуется)",
|
||
value="oauth",
|
||
variable=self.grok_mode_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=Theme.PROVIDER_GROK,
|
||
command=self._on_grok_mode_toggle,
|
||
)
|
||
rb1.pack(anchor="w", padx=4, pady=2)
|
||
|
||
rb2 = ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="API Key — xAI API (xai-...)",
|
||
value="api_key",
|
||
variable=self.grok_mode_var,
|
||
font=Theme.font_body(),
|
||
fg_color=Theme.PROVIDER_GROK,
|
||
command=self._on_grok_mode_toggle,
|
||
)
|
||
rb2.pack(anchor="w", padx=4, pady=2)
|
||
|
||
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||
auth_card.pack(fill="x", pady=(0, 8))
|
||
|
||
ctk.CTkLabel(
|
||
auth_card,
|
||
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))
|
||
|
||
self.grok_url_entry = HubEntry(
|
||
url_row,
|
||
font=Theme.font_mono(),
|
||
height=32,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.grok_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
HubButton(
|
||
url_row,
|
||
text="📋",
|
||
variant="secondary",
|
||
width=40,
|
||
height=32,
|
||
command=self._copy_grok_url,
|
||
).pack(side="right")
|
||
|
||
code_card = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||
code_card.pack(fill="x", padx=10, pady=(0, 6))
|
||
|
||
ctk.CTkLabel(
|
||
code_card,
|
||
text="2. Введите на странице код:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
).pack(side="left", padx=(0, 8))
|
||
|
||
self.grok_code_lbl = ctk.CTkLabel(
|
||
code_card,
|
||
text="...",
|
||
font=("Consolas", 18, "bold"),
|
||
text_color=Theme.PROVIDER_GROK,
|
||
)
|
||
self.grok_code_lbl.pack(side="left", padx=(0, 8))
|
||
|
||
HubButton(
|
||
code_card,
|
||
text="📋 Копировать код",
|
||
variant="secondary",
|
||
width=130,
|
||
height=28,
|
||
command=self._copy_grok_code,
|
||
).pack(side="left")
|
||
|
||
action_row = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||
action_row.pack(fill="x", padx=10, pady=(0, 8))
|
||
|
||
HubButton(
|
||
action_row,
|
||
text="🌐 Открыть в браузере",
|
||
variant="primary",
|
||
width=180,
|
||
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))
|
||
|
||
ctk.CTkLabel(
|
||
manual_card,
|
||
text="▸ Вставить токен вручную:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", padx=10, pady=(6, 2))
|
||
|
||
manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent")
|
||
manual_entry_row.pack(fill="x", padx=10, pady=(0, 6))
|
||
|
||
self.grok_manual_entry = HubEntry(
|
||
manual_entry_row,
|
||
placeholder_text='{"access_token": "..."} или токен...',
|
||
font=Theme.font_mono(),
|
||
height=32,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.grok_manual_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
HubButton(
|
||
manual_entry_row,
|
||
text="📋 Вставить",
|
||
variant="secondary",
|
||
width=80,
|
||
height=32,
|
||
command=lambda: self._paste_into_entry(self.grok_manual_entry),
|
||
).pack(side="right")
|
||
|
||
HubButton(
|
||
manual_card,
|
||
text="✓ Завершить авторизацию",
|
||
variant="secondary",
|
||
height=30,
|
||
command=self._handle_grok_manual_submit,
|
||
).pack(anchor="w", padx=10, pady=(0, 6))
|
||
|
||
self.grok_status_lbl = ctk.CTkLabel(
|
||
self.body,
|
||
text="Создание сессии xAI Grok...",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
)
|
||
self.grok_status_lbl.pack(fill="x", pady=4)
|
||
|
||
self._build_step_2_footer()
|
||
self._init_grok_oauth()
|
||
|
||
def _on_grok_mode_toggle(self):
|
||
self.grok_auth_mode = self.grok_mode_var.get()
|
||
self._show_step_2_auth()
|
||
|
||
def _init_grok_oauth(self):
|
||
try:
|
||
from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session
|
||
|
||
session_id, url, code = start_grok_oauth(self.target_slot)
|
||
self.grok_session_id = session_id
|
||
self.grok_url = url
|
||
self.grok_user_code = code
|
||
|
||
session = get_grok_oauth_session(session_id)
|
||
if not code or (session and session.status == "failed"):
|
||
err_msg = getattr(session, "error_msg", None) or "Не удалось получить код авторизации"
|
||
self.grok_status_lbl.configure(text=f"❌ {err_msg}", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
self.grok_url_entry.delete(0, "end")
|
||
self.grok_url_entry.insert(0, url)
|
||
self.grok_code_lbl.configure(text=code)
|
||
|
||
if getattr(session, "is_dev_mode", False):
|
||
self.grok_status_lbl.configure(
|
||
text=f"⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE): Код {code}",
|
||
text_color=Theme.STATUS_WARNING,
|
||
)
|
||
else:
|
||
self.grok_status_lbl.configure(
|
||
text=f"Ожидание подтверждения кода {code} в браузере...",
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
)
|
||
|
||
self._polling_active = True
|
||
threading.Thread(target=self._poll_grok_oauth, daemon=True).start()
|
||
except Exception as e:
|
||
self.grok_status_lbl.configure(text=f"Ошибка: {e}", text_color=Theme.STATUS_ERROR)
|
||
|
||
def _copy_grok_url(self):
|
||
if self.grok_url:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(self.grok_url)
|
||
|
||
def _copy_grok_code(self):
|
||
if self.grok_user_code:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(self.grok_user_code)
|
||
|
||
def _open_grok_browser(self):
|
||
if self.grok_url:
|
||
self._copy_grok_code()
|
||
self.grok_status_lbl.configure(
|
||
text=(
|
||
"Код скопирован. Вставьте его на открывшейся странице xAI; "
|
||
"затем вернитесь сюда — Hub продолжит автоматически."
|
||
),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
)
|
||
webbrowser.open(self.grok_url)
|
||
|
||
def _handle_grok_manual_submit(self):
|
||
raw = self.grok_manual_entry.get().strip()
|
||
if not raw:
|
||
self.grok_status_lbl.configure(text="❌ Введите данные токена", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
||
|
||
session = get_grok_oauth_session(self.grok_session_id)
|
||
if not session:
|
||
self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
ok, msg = session.handle_manual_input(raw)
|
||
if ok:
|
||
info = getattr(session, "completed_profile_info", {}) or {}
|
||
self.discovered_identity = info.get("email") or "Grok Account"
|
||
self.discovered_plan = "Grok Pro"
|
||
self.discovered_models = ["grok-3", "grok-3-mini", "grok-2"]
|
||
self.is_verified = True
|
||
self._show_step_3_validation()
|
||
else:
|
||
self.grok_status_lbl.configure(text=f"❌ {msg}", text_color=Theme.STATUS_ERROR)
|
||
|
||
def _poll_grok_oauth(self):
|
||
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
||
|
||
for _ in range(900):
|
||
if not self._polling_active:
|
||
return
|
||
time.sleep(1)
|
||
session = get_grok_oauth_session(self.grok_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 "Grok Account"
|
||
self.discovered_plan = "Grok Pro"
|
||
self.discovered_models = ["grok-3", "grok-3-mini", "grok-2"]
|
||
self.is_verified = True
|
||
self.after(0, self._show_step_3_validation)
|
||
return
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# API KEY FLOW
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_api_key_flow(self):
|
||
# Mode switchers for Codex, Claude, Grok
|
||
if self.selected_provider == "openai-codex":
|
||
mode_card = HubCard(self.body, fg_color="transparent")
|
||
mode_card.pack(fill="x", pady=(0, 8))
|
||
self.codex_mode_var = ctk.StringVar(value="api_key")
|
||
ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="OAuth — OpenAI / ChatGPT",
|
||
value="oauth",
|
||
variable=self.codex_mode_var,
|
||
font=Theme.font_body(),
|
||
fg_color=Theme.ACCENT,
|
||
command=self._on_codex_mode_toggle,
|
||
).pack(anchor="w", padx=4, pady=2)
|
||
ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="API Key — OpenAI API (sk-...)",
|
||
value="api_key",
|
||
variable=self.codex_mode_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=Theme.ACCENT,
|
||
command=self._on_codex_mode_toggle,
|
||
).pack(anchor="w", padx=4, pady=2)
|
||
elif self.selected_provider == "claude":
|
||
mode_card = HubCard(self.body, fg_color="transparent")
|
||
mode_card.pack(fill="x", pady=(0, 8))
|
||
self.claude_mode_var = ctk.StringVar(value="api_key")
|
||
ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="OAuth — Claude Pro/Max",
|
||
value="oauth",
|
||
variable=self.claude_mode_var,
|
||
font=Theme.font_body(),
|
||
fg_color=Theme.PROVIDER_CLAUDE,
|
||
command=self._on_claude_mode_toggle,
|
||
).pack(anchor="w", padx=4, pady=2)
|
||
ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="API Key — Anthropic API (sk-ant-...)",
|
||
value="api_key",
|
||
variable=self.claude_mode_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=Theme.PROVIDER_CLAUDE,
|
||
command=self._on_claude_mode_toggle,
|
||
).pack(anchor="w", padx=4, pady=2)
|
||
elif self.selected_provider == "grok":
|
||
mode_card = HubCard(self.body, fg_color="transparent")
|
||
mode_card.pack(fill="x", pady=(0, 8))
|
||
self.grok_mode_var = ctk.StringVar(value="api_key")
|
||
ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="OAuth — xAI / SuperGrok",
|
||
value="oauth",
|
||
variable=self.grok_mode_var,
|
||
font=Theme.font_body(),
|
||
fg_color=Theme.PROVIDER_GROK,
|
||
command=self._on_grok_mode_toggle,
|
||
).pack(anchor="w", padx=4, pady=2)
|
||
ctk.CTkRadioButton(
|
||
mode_card,
|
||
text="API Key — xAI API (xai-...)",
|
||
value="api_key",
|
||
variable=self.grok_mode_var,
|
||
font=Theme.font_body_bold(),
|
||
fg_color=Theme.PROVIDER_GROK,
|
||
command=self._on_grok_mode_toggle,
|
||
).pack(anchor="w", padx=4, pady=2)
|
||
|
||
prompt_map = {
|
||
"openai-codex": "Введите ключ OpenAI API (sk-...):",
|
||
"claude": "Введите ключ Anthropic API (sk-ant-...):",
|
||
"grok": "Введите ключ xAI API (xai-...):",
|
||
"opencode-go": "Введите ключ API / Bearer Token для OpenCode Go:",
|
||
}
|
||
ctk.CTkLabel(
|
||
self.body,
|
||
text=prompt_map.get(self.selected_provider, "Введите ключ API:"),
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
).pack(anchor="w", pady=(0, 6))
|
||
|
||
entry_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
||
entry_row.pack(fill="x", pady=(0, 8))
|
||
|
||
placeholder_map = {
|
||
"openai-codex": "sk-...",
|
||
"claude": "sk-ant-...",
|
||
"grok": "xai-...",
|
||
"opencode-go": "opencode-...",
|
||
}
|
||
self.key_entry = HubEntry(
|
||
entry_row,
|
||
placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."),
|
||
font=Theme.font_mono(),
|
||
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,
|
||
text="📋 Вставить",
|
||
variant="secondary",
|
||
width=90,
|
||
height=38,
|
||
command=lambda: self._paste_into_entry(self.key_entry),
|
||
).pack(side="right")
|
||
|
||
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
|
||
|
||
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)
|
||
self.discovered_plan = "PLUS"
|
||
elif self.selected_provider == "claude":
|
||
is_valid, masked_id, models = ProfileAuthManager.verify_claude_token(k)
|
||
self.discovered_plan = "MAX"
|
||
elif self.selected_provider == "grok":
|
||
is_valid, masked_id, models = ProfileAuthManager.verify_grok_token(k)
|
||
self.discovered_plan = "Grok Pro"
|
||
elif self.selected_provider == "opencode-go":
|
||
is_valid, masked_id, models = ProfileAuthManager.verify_opencode_token(k)
|
||
self.discovered_plan = "SUBSCRIPTION"
|
||
|
||
auth_data = {
|
||
"provider": self.selected_provider,
|
||
"profile_id": self.target_slot,
|
||
"auth_mode": "api_key",
|
||
"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()
|
||
|
||
self._build_step_2_footer(next_cmd=_save_key)
|
||
|
||
def _build_step_2_footer(self, next_cmd: Optional[Callable] = None):
|
||
HubButton(
|
||
self.footer,
|
||
text="← Назад",
|
||
variant="ghost",
|
||
command=self._show_step_1_provider,
|
||
).pack(side="left", padx=10, pady=10)
|
||
|
||
if next_cmd:
|
||
HubButton(
|
||
self.footer,
|
||
text="Проверить и продолжить →",
|
||
variant="primary",
|
||
command=next_cmd,
|
||
).pack(side="right", padx=10, pady=10)
|
||
|
||
def _paste_into_entry(self, entry_widget: HubEntry):
|
||
try:
|
||
content = entry_widget.clipboard_get().strip()
|
||
if content:
|
||
entry_widget.delete(0, "end")
|
||
entry_widget.insert(0, content)
|
||
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}")
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# LOCAL LLM FLOW (llama.cpp / vLLM / Ollama / Local Server)
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_local_llm_flow(self):
|
||
disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot)
|
||
|
||
ctk.CTkLabel(
|
||
self.body,
|
||
text=f"Подключение локального сервера к слоту: {self.target_slot} ({disp_name})",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", pady=(0, 4))
|
||
|
||
info_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||
info_card.pack(fill="x", pady=(0, 8))
|
||
|
||
ctk.CTkLabel(
|
||
info_card,
|
||
text="Подключение к локальному серверу (llama.cpp / vLLM / Ollama / Local LLM):",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
).pack(fill="x", padx=12, pady=(10, 4))
|
||
|
||
ctk.CTkLabel(
|
||
info_card,
|
||
text=(
|
||
"Укажите базовый URL OpenAI-совместимого эндпоинта (например, http://127.0.0.1:8081/v1 или http://localhost:8080/v1).\n"
|
||
"Если сервер защищен токеном, введите его в поле API Key."
|
||
),
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
justify="left",
|
||
anchor="w",
|
||
).pack(fill="x", padx=12, pady=(0, 10))
|
||
|
||
# URL Input
|
||
ctk.CTkLabel(
|
||
self.body,
|
||
text="URL сервера (Base URL):",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
url_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
||
url_row.pack(fill="x", pady=(0, 8))
|
||
|
||
self.local_url_entry = HubEntry(
|
||
url_row,
|
||
placeholder_text="http://127.0.0.1:8081/v1",
|
||
font=Theme.font_mono(),
|
||
height=36,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.local_url_entry.insert(0, "http://127.0.0.1:8081/v1")
|
||
self.local_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||
|
||
HubButton(
|
||
url_row,
|
||
text="📋 Вставить",
|
||
variant="secondary",
|
||
width=90,
|
||
height=36,
|
||
command=lambda: self._paste_into_entry(self.local_url_entry),
|
||
).pack(side="right")
|
||
|
||
# Optional API Key Input
|
||
ctk.CTkLabel(
|
||
self.body,
|
||
text="API Key (опционально):",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
key_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
||
key_row.pack(fill="x", pady=(0, 8))
|
||
|
||
self.local_key_entry = HubEntry(
|
||
key_row,
|
||
placeholder_text="Оставьте пустым, если ключ не требуется",
|
||
font=Theme.font_mono(),
|
||
show="*",
|
||
height=36,
|
||
fg_color=Theme.PRIMARY,
|
||
border_color=Theme.BORDER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.local_key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||
|
||
HubButton(
|
||
key_row,
|
||
text="📋 Вставить",
|
||
variant="secondary",
|
||
width=90,
|
||
height=36,
|
||
command=lambda: self._paste_into_entry(self.local_key_entry),
|
||
).pack(side="right")
|
||
|
||
# Test button and Status label
|
||
test_row = ctk.CTkFrame(self.body, fg_color="transparent")
|
||
test_row.pack(fill="x", pady=(0, 6))
|
||
|
||
self.local_test_btn = HubButton(
|
||
test_row,
|
||
text="🔍 Проверить подключение",
|
||
variant="secondary",
|
||
height=32,
|
||
command=self._test_local_connection,
|
||
)
|
||
self.local_test_btn.pack(side="left")
|
||
|
||
self.local_status_lbl = ctk.CTkLabel(
|
||
self.body,
|
||
text="",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
)
|
||
self.local_status_lbl.pack(fill="x", pady=4)
|
||
|
||
def _save_local():
|
||
url = self.local_url_entry.get().strip()
|
||
key = self.local_key_entry.get().strip()
|
||
if not url:
|
||
self.local_status_lbl.configure(text="Пожалуйста, введите URL сервера.", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
ok, dname, models, err = ProfileAuthManager.verify_local_endpoint(url, key if key else None)
|
||
auth_data = {
|
||
"provider": "local",
|
||
"profile_id": self.target_slot,
|
||
"base_url": url,
|
||
"api_key": key if key else None,
|
||
"models": models if ok else [],
|
||
"created_at": time.time(),
|
||
}
|
||
ProfileAuthManager.save_profile_auth("local", self.target_slot, auth_data)
|
||
|
||
self.is_verified = ok
|
||
self.discovered_identity = url
|
||
self.discovered_plan = "Локальная модель (без квот)"
|
||
self.discovered_models = models if ok else []
|
||
self._show_step_3_validation()
|
||
|
||
self._build_step_2_footer(next_cmd=_save_local)
|
||
|
||
def _test_local_connection(self):
|
||
url = self.local_url_entry.get().strip()
|
||
key = self.local_key_entry.get().strip()
|
||
if not url:
|
||
self.local_status_lbl.configure(text="Пожалуйста, введите URL сервера.", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
self.local_status_lbl.configure(text="⏳ Проверка подключения к серверу...", text_color=Theme.TEXT_SECONDARY)
|
||
|
||
def _worker():
|
||
ok, dname, models, err = ProfileAuthManager.verify_local_endpoint(url, key if key else None)
|
||
if ok:
|
||
m_str = ", ".join(models[:3]) + (f" (+{len(models)-3})" if len(models) > 3 else "")
|
||
self.after(
|
||
0,
|
||
lambda: self.local_status_lbl.configure(
|
||
text=f"✓ Подключение успешно! Доступные модели: {m_str}",
|
||
text_color=Theme.STATUS_HEALTHY,
|
||
),
|
||
)
|
||
else:
|
||
self.after(
|
||
0,
|
||
lambda: self.local_status_lbl.configure(
|
||
text=f"❌ Ошибка подключения: {err}",
|
||
text_color=Theme.STATUS_ERROR,
|
||
),
|
||
)
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# STEP 3: Validation & Identity
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _show_step_3_validation(self):
|
||
self._polling_active = False
|
||
self._clear_body()
|
||
self.step = 3
|
||
self.title_lbl.configure(text="Шаг 3 из 4: Проверка учетной записи")
|
||
|
||
# Duplicate check
|
||
dup_profile = AutoAssigner.check_duplicate_identity(
|
||
self.selected_provider,
|
||
self.discovered_identity,
|
||
exclude_profile_id=self.target_slot,
|
||
)
|
||
|
||
card = HubCard(self.body)
|
||
card.pack(fill="x", pady=10)
|
||
|
||
ctk.CTkLabel(
|
||
card,
|
||
text="Результат проверки подключения:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
).pack(anchor="w", padx=14, pady=(10, 6))
|
||
|
||
# Identity line
|
||
id_row = ctk.CTkFrame(card, fg_color="transparent")
|
||
id_row.pack(fill="x", padx=14, pady=2)
|
||
ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||
side="left"
|
||
)
|
||
ctk.CTkLabel(
|
||
id_row, text=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||
).pack(side="right")
|
||
|
||
# Plan line
|
||
plan_row = ctk.CTkFrame(card, fg_color="transparent")
|
||
plan_row.pack(fill="x", padx=14, pady=2)
|
||
ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||
side="left"
|
||
)
|
||
ctk.CTkLabel(
|
||
plan_row,
|
||
text=self.discovered_plan or "Тариф: неизвестен",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.ACCENT,
|
||
).pack(side="right")
|
||
|
||
# Status line
|
||
stat_row = ctk.CTkFrame(card, fg_color="transparent")
|
||
stat_row.pack(fill="x", padx=14, pady=2)
|
||
ctk.CTkLabel(
|
||
stat_row, text="Статус проверки:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY
|
||
).pack(side="left")
|
||
stat_lbl = "✓ Успешно авторизован" if self.is_verified else "Подключен (без проверки)"
|
||
stat_col = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING
|
||
ctk.CTkLabel(stat_row, text=stat_lbl, font=Theme.font_body_bold(), text_color=stat_col).pack(side="right")
|
||
|
||
if dup_profile:
|
||
warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.ACCENT_DIM)
|
||
warn_card.pack(fill="x", pady=6)
|
||
ctk.CTkLabel(
|
||
warn_card,
|
||
text=f"⚠️ Этот аккаунт уже подключен к слоту {dup_profile}.",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.STATUS_WARNING,
|
||
).pack(padx=10, pady=8)
|
||
|
||
HubButton(
|
||
self.footer,
|
||
text="← Назад",
|
||
variant="ghost",
|
||
command=self._show_step_2_auth,
|
||
).pack(side="left", padx=10, pady=10)
|
||
|
||
HubButton(
|
||
self.footer,
|
||
text="Продолжить к распределению роли →",
|
||
variant="primary",
|
||
command=self._show_step_4_assignment,
|
||
).pack(side="right", padx=10, pady=10)
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# STEP 4: Auto-Assignment & Completion
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _show_step_4_assignment(self):
|
||
self._clear_body()
|
||
self.step = 4
|
||
self.title_lbl.configure(text="Шаг 4 из 4: Назначение роли в роутере")
|
||
|
||
disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot)
|
||
|
||
card = HubCard(self.body)
|
||
card.pack(fill="x", pady=10)
|
||
|
||
ctk.CTkLabel(
|
||
card,
|
||
text="Назначенная роль в команде:",
|
||
font=Theme.font_body_bold(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
).pack(anchor="w", padx=14, pady=(10, 6))
|
||
|
||
r_row = ctk.CTkFrame(card, fg_color="transparent")
|
||
r_row.pack(fill="x", padx=14, pady=4)
|
||
ctk.CTkLabel(r_row, text="Роль агента:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||
side="left"
|
||
)
|
||
ctk.CTkLabel(r_row, text=disp_name, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="right")
|
||
|
||
s_row = ctk.CTkFrame(card, fg_color="transparent")
|
||
s_row.pack(fill="x", padx=14, pady=4)
|
||
ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||
side="left"
|
||
)
|
||
ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack(
|
||
side="right"
|
||
)
|
||
|
||
ctk.CTkLabel(
|
||
self.body,
|
||
text="Аккаунт готов к работе в роутере Hermes Hub и приему запросов.",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
).pack(fill="x", pady=8)
|
||
|
||
self.finish_status_lbl = ctk.CTkLabel(
|
||
self.body,
|
||
text="Нажмите «Завершить подключение», чтобы сохранить роль и обновить Hub.",
|
||
font=Theme.font_caption(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
anchor="w",
|
||
)
|
||
self.finish_status_lbl.pack(fill="x", pady=(0, 4))
|
||
|
||
# Sequential multi-account helper for Antigravity and other providers
|
||
next_slot = AutoAssigner.find_free_slot(self.selected_provider)
|
||
if next_slot and next_slot != self.target_slot:
|
||
HubButton(
|
||
self.footer,
|
||
text=f"➕ Подключить следующий слот ({next_slot}) →",
|
||
variant="secondary",
|
||
command=lambda: self._finish_and_next_slot(next_slot),
|
||
).pack(side="right", padx=(0, 10), pady=10)
|
||
|
||
HubButton(
|
||
self.footer,
|
||
text="✓ Завершить подключение",
|
||
variant="primary",
|
||
command=self._finish,
|
||
).pack(side="right", padx=10, pady=10)
|
||
|
||
def _finish_and_next_slot(self, next_slot: str):
|
||
if not self.target_slot:
|
||
return
|
||
provider = getattr(self, "selected_provider", "antigravity")
|
||
slot = self.target_slot
|
||
profile_ok, _ = AutoAssigner.ensure_profile_definition(provider, slot)
|
||
if not profile_ok:
|
||
return
|
||
ok, _ = ensure_profile_in_routing(slot)
|
||
if not ok:
|
||
return
|
||
try:
|
||
from antigravity_provider.router.router_engine import get_router_engine
|
||
|
||
get_router_engine().health.clear_cooldown(slot)
|
||
EventLogService.get().log(
|
||
"account",
|
||
f"Подключен аккаунт {provider}; слот {slot}; роль сохранена.",
|
||
level="success",
|
||
)
|
||
except Exception:
|
||
pass
|
||
if getattr(self, "on_complete", None):
|
||
try:
|
||
self.on_complete(
|
||
{
|
||
"provider": provider,
|
||
"profile_id": slot,
|
||
"identity": getattr(self, "discovered_identity", ""),
|
||
}
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Reset state for next slot in sequence
|
||
self.target_slot = next_slot
|
||
self.discovered_identity = ""
|
||
self.discovered_plan = "Тариф: неизвестен"
|
||
self.discovered_models = []
|
||
self.is_verified = False
|
||
self.step = 2
|
||
self._show_step_2_auth()
|
||
|
||
def _finish(self):
|
||
if not getattr(self, "target_slot", None):
|
||
if hasattr(self, "finish_status_lbl"):
|
||
self.finish_status_lbl.configure(
|
||
text="❌ Подключение невозможно: свободный слот не найден.",
|
||
text_color=Theme.STATUS_ERROR,
|
||
)
|
||
return
|
||
|
||
provider = getattr(self, "selected_provider", "antigravity")
|
||
slot = self.target_slot
|
||
|
||
profile_ok, profile_message = AutoAssigner.ensure_profile_definition(
|
||
provider,
|
||
slot,
|
||
)
|
||
if not profile_ok:
|
||
if hasattr(self, "finish_status_lbl"):
|
||
self.finish_status_lbl.configure(text=f"❌ {profile_message}", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
ok, message = ensure_profile_in_routing(slot)
|
||
if not ok:
|
||
if hasattr(self, "finish_status_lbl"):
|
||
self.finish_status_lbl.configure(text=f"❌ {message}", text_color=Theme.STATUS_ERROR)
|
||
return
|
||
|
||
try:
|
||
from antigravity_provider.router.router_engine import get_router_engine
|
||
|
||
get_router_engine().health.clear_cooldown(slot)
|
||
EventLogService.get().log(
|
||
"account",
|
||
f"Подключен аккаунт {provider}; слот {slot}; роль сохранена.",
|
||
level="success",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
if getattr(self, "on_complete", None):
|
||
try:
|
||
self.on_complete(
|
||
{
|
||
"provider": provider,
|
||
"profile_id": slot,
|
||
"identity": getattr(self, "discovered_identity", ""),
|
||
}
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
if hasattr(self, "destroy"):
|
||
self.destroy()
|