hermes-hub/src/antigravity_provider/router/ui/add_account_wizard.py

1750 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()