fix: complete B7 live usability flows

This commit is contained in:
Hermes Team 2026-08-22 22:30:41 +07:00
parent f0461bc539
commit 40b3466558
14 changed files with 1057 additions and 164 deletions

View file

@ -172,6 +172,48 @@ class AutoAssigner:
return candidates[0] if candidates else None return candidates[0] if candidates else None
@staticmethod
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
"""Persist a router profile for provider slots introduced by the UI.
Claude and Grok were added after the original static router profile
list. Their OAuth credentials could therefore be saved successfully
while role assignment failed because the profile did not exist in the
router YAML.
"""
config = load_router_config()
if profile_id in config.profiles:
return True, "Профиль уже зарегистрирован"
defaults = {
"grok": (["grok-3", "grok-3-mini", "grok-2"], ["reasoning", "coding", "research"]),
"claude": (
["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
["reasoning", "coding", "review"],
),
"opencode-go": (
["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
["coding", "research", "fast"],
),
"openai-codex": (["gpt-4o", "o3-mini", "codex"], ["coding", "reasoning"]),
"antigravity": (
["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
["coding", "reasoning", "research", "fast"],
),
}
models, capabilities = defaults.get(provider, (["default"], []))
config.profiles[profile_id] = RouterProfileConfig(
profile_id=profile_id,
provider=provider,
account_id=profile_id,
capabilities=list(capabilities),
preferred_models=list(models),
enabled=True,
max_concurrency=1,
)
if not save_router_config(config):
return False, f"Не удалось сохранить профиль '{profile_id}' в конфигурации"
return True, f"Профиль '{profile_id}' зарегистрирован"
@staticmethod @staticmethod
def recommend_assignment(provider: str) -> Tuple[str, str, str]: def recommend_assignment(provider: str) -> Tuple[str, str, str]:
"""Analyze team health and return (recommended_slot, role_title_ru, reason_ru).""" """Analyze team health and return (recommended_slot, role_title_ru, reason_ru)."""

View file

@ -42,7 +42,7 @@ for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.paren
if _p.exists() and _ps not in sys.path: if _p.exists() and _ps not in sys.path:
sys.path.insert(0, _ps) sys.path.insert(0, _ps)
from antigravity_provider.router.router_config import load_router_config from antigravity_provider.router.router_config import load_router_config, save_router_config
from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters import get_adapter
@ -61,7 +61,7 @@ from antigravity_provider.router.unified_health import (
STATUS_HEALTHY, STATUS_HEALTHY,
) )
from antigravity_provider.router.ui.views.team_view import TeamView from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain
from antigravity_provider.router.ui.views.dashboard_view import DashboardView from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.accounts_view import AccountsView from antigravity_provider.router.ui.views.accounts_view import AccountsView
from antigravity_provider.router.ui.views.providers_view import ProvidersView from antigravity_provider.router.ui.views.providers_view import ProvidersView
@ -231,6 +231,18 @@ class HermesHubApp(ctk.CTk):
pass pass
self.after(50, self._refresh_data) self.after(50, self._refresh_data)
self.after(800, self._refresh_quotas_on_startup)
def _refresh_quotas_on_startup(self) -> None:
"""Populate measured quota cards immediately instead of after the 5-minute scheduler tick."""
if self._shutting_down:
return
try:
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
except Exception as exc:
logger.warning("Initial quota refresh could not start: %s", exc)
def _build_layout(self): def _build_layout(self):
# ── Sidebar (Left) ── # ── Sidebar (Left) ──
@ -263,8 +275,8 @@ class HermesHubApp(ctk.CTk):
("team", "Команда", "team"), ("team", "Команда", "team"),
("accounts", "Аккаунты", "accounts"), ("accounts", "Аккаунты", "accounts"),
("routing", "Маршрутизация", "routing"), ("routing", "Маршрутизация", "routing"),
("providers", "Провайдеры", "providers"), ("providers", "Модели и провайдеры", "providers"),
("quotas", "Квоты и лимиты", "quotas"), ("quotas", "Сводка лимитов", "quotas"),
("analytics", "Аналитика", "analytics"), ("analytics", "Аналитика", "analytics"),
("health", "Состояние", "health"), ("health", "Состояние", "health"),
("logs", "Журнал событий", "logs"), ("logs", "Журнал событий", "logs"),
@ -595,28 +607,33 @@ class HermesHubApp(ctk.CTk):
if action == "set_main": if action == "set_main":
self._run_in_thread( self._run_in_thread(
lambda: do_set_main(prov, pid), lambda: do_set_main(prov, pid),
on_success=lambda r: self._show_toast(f"{r[1]}" if r[0] else f"{r[1]}"), on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
) )
elif action == "set_orchestrator": elif action == "set_orchestrator":
self._run_in_thread( self._run_in_thread(
lambda: do_set_orchestrator(pid), lambda: do_set_orchestrator(pid),
on_success=lambda r: self._show_toast(f"{r[1]}" if r[0] else f"{r[1]}"), on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
) )
elif action == "test": elif action == "test":
self._show_toast(f"Тестирование {data.get('display_name', pid)}...") self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}", None)
self._run_in_thread( self._run_in_thread(
lambda: do_test_profile(prov, pid), lambda: do_test_profile(prov, pid),
on_success=self._show_test_result, on_success=lambda result: self._show_test_result(result, pid),
) )
elif action == "oauth" or action == "add_account": elif action == "oauth" or action == "add_account":
self._open_add_account_wizard() self._open_add_account_wizard()
elif action == "delete_credentials": elif action == "delete_credentials":
self._run_in_thread( self._run_in_thread(
lambda: do_delete_credentials(prov, pid), lambda: do_delete_credentials(prov, pid),
on_success=lambda r: self._show_toast(f"{r[1]}" if r[0] else f"{r[1]}"), on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
) )
elif action == "assign_role": elif action == "assign_role":
self._open_assign_role_modal(pid, data.get("display_name", pid)) self._open_assign_role_modal(pid, data.get("display_name", pid))
elif action == "agent_settings":
self._open_agent_settings_modal(
data.get("role_id", ""),
pid,
)
elif action == "auto_assign_all": elif action == "auto_assign_all":
self._show_toast("⚡ Автоматическое распределение ролей...") self._show_toast("⚡ Автоматическое распределение ролей...")
self._run_in_thread( self._run_in_thread(
@ -639,11 +656,7 @@ class HermesHubApp(ctk.CTk):
) )
elif action == "edit_route": elif action == "edit_route":
role_id = data.get("role_id", "") role_id = data.get("role_id", "")
self._show_view("team") self._open_route_editor_modal(role_id)
team = self._views.get("team")
if team and hasattr(team, "focus_role"):
team.focus_role(role_id)
self._show_toast(f"Настройка цепочки роли: {role_id}")
elif action == "open_routing": elif action == "open_routing":
self._show_view("routing") self._show_view("routing")
routing = self._views.get("routing") routing = self._views.get("routing")
@ -724,6 +737,18 @@ class HermesHubApp(ctk.CTk):
hover_color=Theme.ACCENT_HOVER, hover_color=Theme.ACCENT_HOVER,
).pack(anchor="w", padx=8, pady=(12, 3)) ).pack(anchor="w", padx=8, pady=(12, 3))
result_label = ctk.CTkLabel(
modal.body,
text="",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_SECONDARY,
wraplength=440,
justify="left",
anchor="w",
)
result_label.pack(fill="x", padx=8, pady=(8, 0))
modal.result_label = result_label
def _save(): def _save():
chosen = role_var.get() chosen = role_var.get()
ok, msg = AutoAssigner.assign_profile_to_role( ok, msg = AutoAssigner.assign_profile_to_role(
@ -731,12 +756,279 @@ class HermesHubApp(ctk.CTk):
chosen, chosen,
is_primary=primary_var.get() and chosen != "spare", is_primary=primary_var.get() and chosen != "spare",
) )
modal.destroy() result_label.configure(
self._show_toast(f"{msg}" if ok else f"{msg}") text=f"{'' if ok else ''} {msg}",
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
)
self._show_account_action_result(profile_id, msg, ok)
if ok:
save_button.configure(text="Готово", command=modal.destroy)
self._refresh_data() self._refresh_data()
HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left") HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left")
HubButton(modal.footer, text="Применить роль", variant="primary", width=160, command=_save).pack(side="right") save_button = HubButton(
modal.footer, text="Применить роль", variant="primary", width=160, command=_save
)
save_button.pack(side="right")
modal.save_button = save_button
return modal
def _open_route_editor_modal(self, role_id: str):
"""Direct, visible editor for one ordered failover chain."""
config = load_router_config()
policy = config.roles.get(role_id)
if policy is None:
self._show_toast(f"❌ Роль '{role_id}' не найдена")
return None
modal = HubModal(self, title=f"Цепочка маршрутизации: {role_id}", width=620, height=520)
ctk.CTkLabel(
modal.body,
text="Первый профиль — основной. Ниже идут резервы в порядке переключения.",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", pady=(0, 10))
chain = list(policy.preferred_chain)
rows = ctk.CTkFrame(modal.body, fg_color="transparent")
rows.pack(fill="both", expand=True)
result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
result.pack(fill="x", pady=(6, 0))
def _render() -> None:
for child in rows.winfo_children():
child.destroy()
for index, pid in enumerate(chain):
pcfg = load_router_config().profiles.get(pid)
row = ctk.CTkFrame(rows, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
row.pack(fill="x", pady=3)
ctk.CTkLabel(
row,
text=f"{index + 1}. {pid}",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=10, pady=8)
ctk.CTkLabel(
row,
text=(pcfg.provider if pcfg else "профиль не найден"),
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
).pack(side="left", padx=6)
def _move(delta: int, current: int = index) -> None:
target = current + delta
if 0 <= target < len(chain):
chain[current], chain[target] = chain[target], chain[current]
_render()
def _remove(current: int = index) -> None:
chain.pop(current)
_render()
HubButton(row, text="Удалить", variant="ghost", width=70, command=_remove).pack(
side="right", padx=(2, 8), pady=5
)
HubButton(row, text="", variant="secondary", width=34, command=lambda i=index: _move(1, i)).pack(
side="right", padx=2, pady=5
)
HubButton(row, text="", variant="secondary", width=34, command=lambda i=index: _move(-1, i)).pack(
side="right", padx=2, pady=5
)
add_row = ctk.CTkFrame(modal.body, fg_color="transparent")
add_row.pack(fill="x", pady=(8, 0))
# Keep every profile in the selector so a just-removed item can be
# re-added immediately without closing and reopening the editor.
available = list(config.profiles)
add_var = ctk.StringVar(value=available[0] if available else "Нет доступных профилей")
add_menu = ctk.CTkOptionMenu(
add_row,
values=available or ["Нет доступных профилей"],
variable=add_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
text_color=Theme.TEXT_PRIMARY,
)
add_menu.pack(side="left", fill="x", expand=True)
def _add() -> None:
pid = add_var.get()
if pid in config.profiles and pid not in chain:
chain.append(pid)
_render()
HubButton(add_row, text="+ Добавить в цепочку", variant="secondary", command=_add).pack(side="right", padx=(8, 0))
def _save_chain() -> None:
ok, message = persist_role_chain(role_id, chain)
result.configure(
text=f"{'' if ok else ''} {message}",
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
)
if ok:
save_button.configure(text="Готово", command=modal.destroy)
self._refresh_data()
_render()
HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left")
save_button = HubButton(modal.footer, text="Сохранить цепочку", variant="primary", command=_save_chain)
save_button.pack(side="right")
return modal
def _open_agent_settings_modal(self, role_id: str, profile_id: str):
"""Open practical role settings from a click anywhere on an agent card."""
from antigravity_provider.router.quota_collector import AccountQuotaService
config = load_router_config()
role = config.roles.get(role_id)
role_labels = {
"orchestrator": "Главный оркестратор",
"coder-primary": "Кодер 1",
"coder-secondary": "Кодер 2",
"reviewer": "Ревьюер",
"research": "Исследователь",
"fast": "Быстрый агент",
}
modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=560, height=540)
ctk.CTkLabel(
modal.body,
text="Аккаунт, модель и реальные лимиты агента",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", pady=(0, 12))
choices: dict[str, str] = {}
for pid, pcfg in config.profiles.items():
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
if not status.get("authenticated"):
continue
identity = AccountQuotaService.get().get_identity(pcfg.provider, pid).primary_identifier()
label = f"{pid}{identity}"
choices[label] = pid
if not choices:
ctk.CTkLabel(
modal.body,
text="Нет подключённых аккаунтов. Сначала добавьте аккаунт.",
text_color=Theme.STATUS_WARNING,
).pack(anchor="w")
HubButton(modal.footer, text="Закрыть", variant="secondary", command=modal.destroy).pack(side="right")
return modal
selected_label = next((label for label, pid in choices.items() if pid == profile_id), next(iter(choices)))
account_var = ctk.StringVar(value=selected_label)
model_var = ctk.StringVar(value="")
ctk.CTkLabel(modal.body, text="Аккаунт", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
anchor="w"
)
account_menu = ctk.CTkOptionMenu(
modal.body,
values=list(choices),
variable=account_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
button_hover_color=Theme.ACCENT_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
account_menu.pack(fill="x", pady=(3, 10))
ctk.CTkLabel(modal.body, text="Модель агента", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
anchor="w"
)
model_menu = ctk.CTkOptionMenu(
modal.body,
values=["default"],
variable=model_var,
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.ACCENT,
button_hover_color=Theme.ACCENT_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
model_menu.pack(fill="x", pady=(3, 10))
quota_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
quota_card.pack(fill="both", expand=True, pady=(2, 10))
quota_title = ctk.CTkLabel(
quota_card, text="Реальные лимиты", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
)
quota_title.pack(anchor="w", padx=12, pady=(10, 5))
quota_rows = ctk.CTkFrame(quota_card, fg_color="transparent")
quota_rows.pack(fill="both", expand=True, padx=12, pady=(0, 8))
provider_models = {
"antigravity": [
"gemini-3.7-flash",
"gemini-3.6-flash-high",
"gemini-3.5-flash",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
],
"openai-codex": ["gpt-4o", "o3-mini", "codex"],
"opencode-go": ["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
"claude": ["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
"grok": ["grok-3", "grok-3-mini", "grok-2"],
}
def _refresh_account_panel(_choice: Optional[str] = None) -> None:
current_pid = choices[account_var.get()]
current_cfg = load_router_config().profiles[current_pid]
available = list(dict.fromkeys(current_cfg.preferred_models + provider_models.get(current_cfg.provider, [])))
if not available:
available = ["default"]
model_menu.configure(values=available)
current_model = role.default_model if role and role.default_model in available else available[0]
model_var.set(current_model)
for child in quota_rows.winfo_children():
child.destroy()
snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid)
buckets = list(snapshot.buckets) if snapshot else []
quota_title.configure(text=f"Реальные лимиты • {current_cfg.provider}")
if not buckets:
ctk.CTkLabel(
quota_rows, text="Лимиты пока не получены", text_color=Theme.TEXT_MUTED, font=Theme.font_caption()
).pack(anchor="w", pady=5)
for bucket in buckets[:6]:
row = ctk.CTkFrame(quota_rows, fg_color="transparent")
row.pack(fill="x", pady=3)
ctk.CTkLabel(
row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
).pack(side="left")
reset = bucket.formatted_reset() or "Сброс: Н"
ctk.CTkLabel(
row,
text=f"{bucket.formatted_remaining()}{reset}",
font=Theme.font_caption(),
text_color=Theme.STATUS_HEALTHY if bucket.status == "healthy" else Theme.STATUS_WARNING,
).pack(side="right")
account_menu.configure(command=_refresh_account_panel)
_refresh_account_panel()
result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
result.pack(fill="x")
def _save_agent() -> None:
current_pid = choices[account_var.get()]
current_model = model_var.get()
updated = load_router_config()
profile = updated.profiles[current_pid]
profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model]
updated.profiles[current_pid] = profile
if role_id in updated.roles:
updated.roles[role_id].default_model = current_model
if not save_router_config(updated):
result.configure(text="Не удалось сохранить модель", text_color=Theme.STATUS_ERROR)
return
ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True)
result.configure(
text=f"{'' if ok else ''} {message}",
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
)
if ok:
save_button.configure(text="Готово", command=modal.destroy)
self._refresh_data()
HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left")
save_button = HubButton(modal.footer, text="Сохранить настройки", variant="primary", command=_save_agent)
save_button.pack(side="right")
return modal
def _open_add_account_wizard(self): def _open_add_account_wizard(self):
wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete) wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete)
@ -745,12 +1037,19 @@ class HermesHubApp(ctk.CTk):
self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён") self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён")
self._refresh_data() self._refresh_data()
def _show_test_result(self, result: Dict[str, Any]): def _show_account_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> None:
view = self._views.get("accounts")
if view and hasattr(view, "show_action_result"):
view.show_action_result(profile_id, message, success)
prefix = "" if success is True else "" if success is False else ""
self._show_toast(f"{prefix} {message}")
def _show_test_result(self, result: Dict[str, Any], profile_id: str = ""):
if result.get("success"): if result.get("success"):
msg = f"✓ Профиль готов | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s" msg = f"Профиль готов • модель: {result.get('model')} • время: {result.get('duration_sec')} с"
else: else:
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" msg = f"Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_toast(msg) self._show_account_action_result(profile_id, msg, bool(result.get("success")))
def _apply_theme(self, scheme: str) -> None: def _apply_theme(self, scheme: str) -> None:
"""Apply all palette tokens by rebuilding presentation widgets in place.""" """Apply all palette tokens by rebuilding presentation widgets in place."""

View file

@ -15,6 +15,8 @@ import logging
import os import os
import threading import threading
import time import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
@ -164,17 +166,14 @@ class AccountQuotaService:
def fetch_all_configured(self, force: bool = False) -> Dict[str, QuotaSnapshot]: def fetch_all_configured(self, force: bool = False) -> Dict[str, QuotaSnapshot]:
"""Fetch quota for all configured profiles across all providers.""" """Fetch quota for all configured profiles across all providers."""
results: Dict[str, QuotaSnapshot] = {} results: Dict[str, QuotaSnapshot] = {}
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"] from antigravity_provider.router.router_config import load_router_config
for prov in providers: for profile_id, profile in load_router_config().profiles.items():
# Check slots auth = ProfileAuthManager.load_profile_auth(profile.provider, profile_id)
from antigravity_provider.router.auto_assigner import AutoAssigner if not auth:
slots = AutoAssigner.PRESET_SLOTS.get(prov, []) continue
for slot_id in slots: snap = self.fetch_account_quota(profile.provider, profile_id, force=force)
auth = ProfileAuthManager.load_profile_auth(prov, slot_id) results[f"{profile.provider}:{profile_id}"] = snap
if auth:
snap = self.fetch_account_quota(prov, slot_id, force=force)
results[f"{prov}:{slot_id}"] = snap
return results return results
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@ -313,60 +312,99 @@ class AccountQuotaService:
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
def _collect_antigravity_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: def _collect_antigravity_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
"""Collect separate Claude (5h, Weekly) and Gemini (5h, Weekly) quota pools for Google Antigravity.""" """Read measured per-model capacity from the official Cloud Code endpoint.
now = _utc_now()
claude_reset_5h = now + timedelta(hours=5)
gemini_reset_5h = now + timedelta(hours=5)
weekly_reset = now + timedelta(days=7)
# Build separate capacity buckets The endpoint exposes one live capacity pool per model, not artificial
b_claude_5h = QuotaBucket( ``5h``/``weekly`` pairs. We therefore show the minimum remaining value
id="antigravity.claude.5h", in each model family. This keeps the compact card useful while never
display_name="Claude 5h", presenting an invented period or percentage.
model_family="claude", """
used_percent=None, from antigravity_provider.cloudcode import antigravity_user_agent, load_or_onboard_project
remaining_percent=None, from antigravity_provider.oauth import refresh_access_token
period="5h",
reset_at=claude_reset_5h, now = _utc_now()
status="unknown", token_data = auth_data.get("token") or auth_data.get("tokens") or auth_data
if not isinstance(token_data, dict):
raise RuntimeError("В профиле Antigravity отсутствует структура OAuth-токена")
access_token = token_data.get("access_token") or token_data.get("access")
if not access_token:
raise RuntimeError("В профиле Antigravity отсутствует access token")
project_id = auth_data.get("project_id") or auth_data.get("projectId")
if not project_id:
project_id = load_or_onboard_project(str(access_token))
def _fetch(token: str) -> dict[str, Any]:
request = urllib.request.Request(
"https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
data=json.dumps({"project": project_id}).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": antigravity_user_agent(),
},
method="POST",
) )
b_claude_weekly = QuotaBucket( with urllib.request.urlopen(request, timeout=30) as response:
id="antigravity.claude.weekly", return json.loads(response.read().decode("utf-8") or "{}")
display_name="Claude Weekly",
model_family="claude", try:
used_percent=None, payload = _fetch(str(access_token))
remaining_percent=None, except urllib.error.HTTPError as exc:
period="7d", refresh_token = token_data.get("refresh_token") or token_data.get("refresh")
reset_at=weekly_reset, if exc.code != 401 or not refresh_token:
status="unknown", raise
refreshed = refresh_access_token(str(refresh_token))
token_data.update(refreshed)
auth_data["token"] = token_data
auth_data["project_id"] = project_id
ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data)
payload = _fetch(str(refreshed["access_token"]))
models = payload.get("models") or {}
if not isinstance(models, dict):
raise RuntimeError("Cloud Code вернул некорректный список моделей")
family_values: dict[str, list[tuple[float, Optional[datetime]]]] = {"claude": [], "gemini": []}
for model_id, model_data in models.items():
if not isinstance(model_data, dict):
continue
lowered = str(model_id).lower()
family = "claude" if "claude" in lowered else ("gemini" if "gemini" in lowered else None)
quota_info = model_data.get("quotaInfo") or {}
remaining_fraction = quota_info.get("remainingFraction") if isinstance(quota_info, dict) else None
if family is None or not isinstance(remaining_fraction, (int, float)):
continue
reset_at = _parse_datetime(quota_info.get("resetTime"))
family_values[family].append((max(0.0, min(100.0, float(remaining_fraction) * 100.0)), reset_at))
buckets: list[QuotaBucket] = []
for family, display_name in (("claude", "Claude • модели"), ("gemini", "Gemini • модели")):
values = family_values[family]
if not values:
continue
remaining, reset_at = min(values, key=lambda item: item[0])
buckets.append(
QuotaBucket(
id=f"antigravity.{family}.model_pool",
display_name=display_name,
model_family=family,
remaining_percent=remaining,
reset_at=reset_at,
period="provider",
unit="model capacity",
scope="model_family",
) )
b_gemini_5h = QuotaBucket(
id="antigravity.gemini.5h",
display_name="Gemini 5h",
model_family="gemini",
used_percent=None,
remaining_percent=None,
period="5h",
reset_at=gemini_reset_5h,
status="unknown",
)
b_gemini_weekly = QuotaBucket(
id="antigravity.gemini.weekly",
display_name="Gemini Weekly",
model_family="gemini",
used_percent=None,
remaining_percent=None,
period="7d",
reset_at=weekly_reset,
status="unknown",
) )
if not buckets:
raise RuntimeError("Cloud Code не вернул измеряемые квоты Claude или Gemini")
return QuotaSnapshot( return QuotaSnapshot(
account_id=profile_id, account_id=profile_id,
provider="antigravity", provider="antigravity",
buckets=[b_claude_5h, b_claude_weekly, b_gemini_5h, b_gemini_weekly], buckets=buckets,
fetched_at=now, fetched_at=now,
source="baseline", source="provider_api",
) )
def _collect_codex_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot: def _collect_codex_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:

View file

@ -212,9 +212,10 @@ class AddAccountWizard(HubModal):
self._clear_body() self._clear_body()
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи") self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
self.target_slot = ( self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or ""
AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1" if not self.target_slot:
) self._show_no_free_slot()
return
if self.selected_provider == "antigravity": if self.selected_provider == "antigravity":
self._build_antigravity_oauth_flow() self._build_antigravity_oauth_flow()
@ -236,6 +237,36 @@ class AddAccountWizard(HubModal):
else: else:
self._build_api_key_flow() self._build_api_key_flow()
def _show_no_free_slot(self) -> None:
"""Stop before OAuth when the configured provider has no real free slot."""
provider_labels = {
"antigravity": "Google Antigravity",
"openai-codex": "OpenAI Codex",
"opencode-go": "OpenCode Go",
"claude": "Claude",
"grok": "Grok",
}
card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED)
card.pack(fill="x", pady=20)
ctk.CTkLabel(
card,
text="Нет свободного слота",
font=Theme.font_heading(),
text_color=Theme.STATUS_WARNING,
).pack(anchor="w", padx=16, pady=(14, 6))
ctk.CTkLabel(
card,
text=(
f"Все слоты провайдера {provider_labels.get(self.selected_provider, self.selected_provider)} заняты.\n"
"Освободите один слот или удалите неиспользуемый аккаунт, затем повторите подключение."
),
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
justify="left",
wraplength=520,
).pack(anchor="w", padx=16, pady=(0, 14))
self._build_step_2_footer()
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# GOOGLE ANTIGRAVITY OAUTH FLOW # GOOGLE ANTIGRAVITY OAUTH FLOW
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@ -484,22 +515,10 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel( ctk.CTkLabel(
auth_card, auth_card,
text=( text="1. Откройте ссылку — кнопка ниже откроет страницу OpenAI",
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице OpenAI. " font=Theme.font_body_bold(),
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется." text_color=Theme.TEXT_PRIMARY,
), ).pack(anchor="w", padx=10, pady=(8, 2))
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=520,
justify="left",
).pack(anchor="w", padx=10, pady=(7, 2))
ctk.CTkLabel(
auth_card,
text="Ссылка для входа в OpenAI (ChatGPT):",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=10, pady=(6, 2))
url_row = ctk.CTkFrame(auth_card, fg_color="transparent") url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6)) url_row.pack(fill="x", padx=10, pady=(0, 6))
@ -516,9 +535,9 @@ class AddAccountWizard(HubModal):
HubButton( HubButton(
url_row, url_row,
text="📋", text="Копировать ссылку",
variant="secondary", variant="secondary",
width=40, width=130,
height=32, height=32,
command=self._copy_codex_url, command=self._copy_codex_url,
).pack(side="right") ).pack(side="right")
@ -528,7 +547,7 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel( ctk.CTkLabel(
code_card, code_card,
text="Код подтверждения:", text="2. Введите на странице код:",
font=Theme.font_body_bold(), font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY, text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8)) ).pack(side="left", padx=(0, 8))
@ -536,7 +555,7 @@ class AddAccountWizard(HubModal):
self.codex_code_lbl = ctk.CTkLabel( self.codex_code_lbl = ctk.CTkLabel(
code_card, code_card,
text="...", text="...",
font=Theme.font_mono_bold(), font=("Consolas", 18, "bold"),
text_color=Theme.ACCENT, text_color=Theme.ACCENT,
) )
self.codex_code_lbl.pack(side="left", padx=(0, 8)) self.codex_code_lbl.pack(side="left", padx=(0, 8))
@ -561,6 +580,13 @@ class AddAccountWizard(HubModal):
command=self._open_codex_browser, command=self._open_codex_browser,
).pack(side="left", padx=(0, 8)) ).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 Fallback Card
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
manual_card.pack(fill="x", pady=(0, 8)) manual_card.pack(fill="x", pady=(0, 8))
@ -779,9 +805,9 @@ class AddAccountWizard(HubModal):
HubButton( HubButton(
url_row, url_row,
text="📋", text="Копировать ссылку",
variant="secondary", variant="secondary",
width=40, width=130,
height=32, height=32,
command=self._copy_claude_url, command=self._copy_claude_url,
).pack(side="right") ).pack(side="right")
@ -940,22 +966,10 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel( ctk.CTkLabel(
auth_card, auth_card,
text=( text="1. Откройте ссылку — кнопка ниже откроет страницу xAI",
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице xAI. " font=Theme.font_body_bold(),
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется." text_color=Theme.TEXT_PRIMARY,
), ).pack(anchor="w", padx=10, pady=(8, 2))
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=520,
justify="left",
).pack(anchor="w", padx=10, pady=(7, 2))
ctk.CTkLabel(
auth_card,
text="Ссылка для входа в xAI Grok:",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=10, pady=(6, 2))
url_row = ctk.CTkFrame(auth_card, fg_color="transparent") url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6)) url_row.pack(fill="x", padx=10, pady=(0, 6))
@ -984,7 +998,7 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel( ctk.CTkLabel(
code_card, code_card,
text="Код подтверждения:", text="2. Введите на странице код:",
font=Theme.font_body_bold(), font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY, text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8)) ).pack(side="left", padx=(0, 8))
@ -992,7 +1006,7 @@ class AddAccountWizard(HubModal):
self.grok_code_lbl = ctk.CTkLabel( self.grok_code_lbl = ctk.CTkLabel(
code_card, code_card,
text="...", text="...",
font=Theme.font_mono_bold(), font=("Consolas", 18, "bold"),
text_color=Theme.PROVIDER_GROK, text_color=Theme.PROVIDER_GROK,
) )
self.grok_code_lbl.pack(side="left", padx=(0, 8)) self.grok_code_lbl.pack(side="left", padx=(0, 8))
@ -1017,6 +1031,13 @@ class AddAccountWizard(HubModal):
command=self._open_grok_browser, command=self._open_grok_browser,
).pack(side="left", padx=(0, 8)) ).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 Entry Card
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
manual_card.pack(fill="x", pady=(0, 8)) manual_card.pack(fill="x", pady=(0, 8))
@ -1277,13 +1298,14 @@ class AddAccountWizard(HubModal):
entry_row, entry_row,
placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."), placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."),
font=Theme.font_mono(), font=Theme.font_mono(),
show="*", show="" if self.selected_provider == "opencode-go" else "*",
height=38, height=38,
fg_color=Theme.PRIMARY, fg_color=Theme.PRIMARY,
border_color=Theme.BORDER, border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY, text_color=Theme.TEXT_PRIMARY,
) )
self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
self.key_entry.focus_set()
HubButton( HubButton(
entry_row, entry_row,
@ -1359,12 +1381,19 @@ class AddAccountWizard(HubModal):
def _paste_into_entry(self, entry_widget: HubEntry): def _paste_into_entry(self, entry_widget: HubEntry):
try: try:
content = self.clipboard_get().strip() content = entry_widget.clipboard_get().strip()
if content: if content:
entry_widget.delete(0, "end") entry_widget.delete(0, "end")
entry_widget.insert(0, content) entry_widget.insert(0, content)
except Exception: entry_widget.focus_set()
pass entry_widget.icursor("end")
if hasattr(self, "key_status_lbl") and entry_widget is getattr(self, "key_entry", None):
self.key_status_lbl.configure(text="✓ Ключ вставлен. Нажмите «Проверить и продолжить».")
elif hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text="Буфер обмена пуст.")
except Exception as exc:
if hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}")
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# STEP 3: Validation & Identity # STEP 3: Validation & Identity
@ -1512,6 +1541,19 @@ class AddAccountWizard(HubModal):
).pack(side="right", padx=10, pady=10) ).pack(side="right", padx=10, pady=10)
def _finish(self): def _finish(self):
if not self.target_slot:
self.finish_status_lbl.configure(
text="❌ Подключение невозможно: свободный слот не найден.",
text_color=Theme.STATUS_ERROR,
)
return
profile_ok, profile_message = AutoAssigner.ensure_profile_definition(
self.selected_provider,
self.target_slot,
)
if not profile_ok:
self.finish_status_lbl.configure(text=f"{profile_message}", text_color=Theme.STATUS_ERROR)
return
# Most built-in slots already occur in a default chain. Custom or # Most built-in slots already occur in a default chain. Custom or
# repaired configs may not, so completion makes that invariant explicit # repaired configs may not, so completion makes that invariant explicit
# without reordering a slot that is already assigned. # without reordering a slot that is already assigned.

View file

@ -888,6 +888,31 @@ class AccountCardWidget(HubCard):
self.status = StatusBadge(self, status) self.status = StatusBadge(self, status)
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.compact_quota = ctk.CTkFrame(self, fg_color="transparent")
self.compact_quota_labels = [
ctk.CTkLabel(
self.compact_quota,
text="",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
)
for _ in range(4)
]
for index, label in enumerate(self.compact_quota_labels):
label.grid(row=index // 2, column=index % 2, sticky="ew", padx=(0, 8), pady=2)
self.compact_quota.grid_columnconfigure(index % 2, weight=1)
self.compact_actions = ctk.CTkFrame(self, fg_color="transparent")
for text, action in (("", "test"), ("", "refresh_account"), ("Роль", "assign_role")):
ActionButton(
self.compact_actions,
text=text,
variant="ghost",
width=48,
height=Theme.HEIGHT_BTN_SM,
command=lambda action_name=action: self._trigger(action_name),
).pack(side="left", padx=(0, Theme.SPACE_XS))
self.details = ctk.CTkFrame(self, fg_color="transparent") self.details = ctk.CTkFrame(self, fg_color="transparent")
self.details.pack(fill="x") self.details.pack(fill="x")
self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent") self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent")
@ -897,6 +922,15 @@ class AccountCardWidget(HubCard):
) )
self.actions = ctk.CTkFrame(self.details, fg_color="transparent") self.actions = ctk.CTkFrame(self.details, fg_color="transparent")
self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
self.action_feedback = ctk.CTkLabel(
self.actions,
text="",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=330,
justify="left",
anchor="w",
)
self.action_buttons: Dict[str, ActionButton] = {} self.action_buttons: Dict[str, ActionButton] = {}
management = ctk.CTkFrame(self.actions, fg_color="transparent") management = ctk.CTkFrame(self.actions, fg_color="transparent")
management.pack(fill="x", pady=(0, Theme.SPACE_XS)) management.pack(fill="x", pady=(0, Theme.SPACE_XS))
@ -944,8 +978,19 @@ class AccountCardWidget(HubCard):
def _trigger(self, action: str) -> None: def _trigger(self, action: str) -> None:
if self.on_action and self.profile_model is not None: if self.on_action and self.profile_model is not None:
self.set_action_feedback("Выполняется…", None)
self.on_action(action, self.profile_model) self.on_action(action, self.profile_model)
def set_action_feedback(self, message: str, success: Optional[bool]) -> None:
"""Show an action result where the user initiated it."""
color = Theme.TEXT_SECONDARY
if success is True:
color = Theme.STATUS_HEALTHY
elif success is False:
color = Theme.STATUS_ERROR
self.action_feedback.configure(text=message, text_color=color)
self.action_feedback.pack(fill="x", pady=(0, Theme.SPACE_XS), before=self.actions.winfo_children()[1])
def toggle_compact(self) -> None: def toggle_compact(self) -> None:
self.set_compact(not self.compact) self.set_compact(not self.compact)
@ -954,7 +999,11 @@ class AccountCardWidget(HubCard):
self.toggle.configure(text="" if compact else "") self.toggle.configure(text="" if compact else "")
if compact: if compact:
self.details.pack_forget() self.details.pack_forget()
self.compact_quota.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_XS))
self.compact_actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
else: else:
self.compact_quota.pack_forget()
self.compact_actions.pack_forget()
self.details.pack(fill="x") self.details.pack(fill="x")
def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None: def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None:
@ -981,6 +1030,22 @@ class AccountCardWidget(HubCard):
snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None) snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None)
buckets = list(getattr(snapshot, "buckets", None) or []) buckets = list(getattr(snapshot, "buckets", None) or [])
estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True
for index, label in enumerate(self.compact_quota_labels):
if index < len(buckets):
bucket = buckets[index]
label.configure(
text=f"{bucket.display_name}: {bucket.formatted_remaining()}",
text_color=(
Theme.STATUS_HEALTHY
if getattr(bucket, "status", "unknown") == "healthy"
else Theme.STATUS_WARNING
if getattr(bucket, "status", "unknown") == "warning"
else Theme.TEXT_MUTED
),
)
label.grid()
else:
label.grid_remove()
seen: set[str] = set() seen: set[str] = set()
for bucket in buckets: for bucket in buckets:
key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket")) key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket"))

View file

@ -203,6 +203,7 @@ class AccountsView(ctk.CTkFrame):
return return
self._snapshot = snapshot self._snapshot = snapshot
live_ids = {profile.profile_id for profile in self._profiles()} live_ids = {profile.profile_id for profile in self._profiles()}
collapse_new_collection = len(self._cards) <= 1 and len(live_ids) > 1
for profile_id in list(self._cards): for profile_id in list(self._cards):
if profile_id not in live_ids: if profile_id not in live_ids:
self._cards.pop(profile_id).destroy() self._cards.pop(profile_id).destroy()
@ -220,11 +221,15 @@ class AccountsView(ctk.CTkFrame):
profile.profile_id, profile.profile_id,
AccountCardWidget.resolve_identity(profile), AccountCardWidget.resolve_identity(profile),
profile.provider_display_name, profile.provider_display_name,
compact=len(live_ids) > 1,
on_action=self._emit, on_action=self._emit,
) )
self._cards[profile.profile_id] = card self._cards[profile.profile_id] = card
self.cards_created += 1 self.cards_created += 1
card.update_account(profile, snapshot.quotas.get(profile.profile_id)) card.update_account(profile, snapshot.quotas.get(profile.profile_id))
if collapse_new_collection:
for card in self._cards.values():
card.set_compact(True)
self._render_visibility() self._render_visibility()
def _render_visibility(self) -> None: def _render_visibility(self) -> None:
@ -267,3 +272,11 @@ class AccountsView(ctk.CTkFrame):
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()), "quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()), "quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
} }
def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool:
"""Keep account action feedback next to the originating controls."""
card = self._cards.get(profile_id)
if card is None:
return False
card.set_action_feedback(message, success)
return True

View file

@ -93,6 +93,21 @@ class _EndpointCard(HubCard):
) )
self.progress.pack(fill="x", pady=(5, 0)) self.progress.pack(fill="x", pady=(5, 0))
self.progress.set(0) self.progress.set(0)
self._click_action: Optional[Callable[[], None]] = None
self._bind_click_tree(self)
def _bind_click_tree(self, widget: Any) -> None:
widget.bind("<Button-1>", self._activate, add="+")
for child in widget.winfo_children():
self._bind_click_tree(child)
def set_click_action(self, action: Optional[Callable[[], None]]) -> None:
self._click_action = action
self.configure(cursor="hand2" if action else "arrow")
def _activate(self, _event: Any = None) -> None:
if self._click_action:
self._click_action()
def update_card( def update_card(
self, self,
@ -162,16 +177,16 @@ class _RouteDiagram(ctk.CTkFrame):
"""Responsive diagram with smooth connections behind native widgets.""" """Responsive diagram with smooth connections behind native widgets."""
def __init__(self, master: Any): def __init__(self, master: Any):
super().__init__(master, height=330, fg_color="transparent") super().__init__(master, height=440, fg_color="transparent")
self.pack_propagate(False) self.pack_propagate(False)
self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0) self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1) self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1)
self.provider_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)] self.provider_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)]
self.agent_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)] self.agent_slots = [ctk.CTkFrame(self, height=68, fg_color="transparent") for _ in range(5)]
for index, slot in enumerate(self.provider_slots): for index, slot in enumerate(self.provider_slots):
slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30) slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30)
for index, slot in enumerate(self.agent_slots): for index, slot in enumerate(self.agent_slots):
slot.place(relx=0.71, rely=0.02 + index * 0.29, relwidth=0.278) slot.place(relx=0.71, rely=0.01 + index * 0.185, relwidth=0.278)
self.orchestrator = _OrchestratorNode(self) self.orchestrator = _OrchestratorNode(self)
self.orchestrator.place(relx=0.51, rely=0.46, anchor="center") self.orchestrator.place(relx=0.51, rely=0.46, anchor="center")
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46) self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
@ -184,12 +199,12 @@ class _RouteDiagram(ctk.CTkFrame):
) )
self.context_status.pack() self.context_status.pack()
self._left_labels = ["", "", ""] self._left_labels = ["", "", ""]
self._right_labels = ["", "", ""] self._right_labels = ["", "", "", "", ""]
self.bind("<Configure>", self._redraw) self.bind("<Configure>", self._redraw)
def set_labels(self, left: list[str], right: list[str]) -> None: def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = (left + ["", "", ""])[:3] self._left_labels = (left + ["", "", ""])[:3]
self._right_labels = (right + ["", "", ""])[:3] self._right_labels = (right + ["", "", "", "", ""])[:5]
self._redraw() self._redraw()
def _redraw(self, _event: Any = None) -> None: def _redraw(self, _event: Any = None) -> None:
@ -197,8 +212,8 @@ class _RouteDiagram(ctk.CTkFrame):
self.canvas.delete("route") self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40 center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71 left_x, right_x = width * 0.312, width * 0.71
ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)] left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
for index, y_pos in enumerate(ys): for index, y_pos in enumerate(left_ys):
self.canvas.create_line( self.canvas.create_line(
left_x, left_x,
y_pos, y_pos,
@ -223,6 +238,8 @@ class _RouteDiagram(ctk.CTkFrame):
anchor="w", anchor="w",
tags="route", tags="route",
) )
right_ys = [height * (0.01 + index * 0.185) + 34 for index in range(5)]
for index, y_pos in enumerate(right_ys):
self.canvas.create_line( self.canvas.create_line(
center_x + 48, center_x + 48,
center_y, center_y,
@ -401,7 +418,7 @@ class DashboardView(ctk.CTkFrame):
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi") self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения") self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения")
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "активно: 0") self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "активно: 0")
self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли") self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы")
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50") self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover") self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
for index, metric in enumerate( for index, metric in enumerate(
@ -411,10 +428,9 @@ class DashboardView(ctk.CTkFrame):
body = ctk.CTkFrame(self.scroll, fg_color="transparent") body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x") body.pack(fill="x")
body.grid_columnconfigure(0, weight=4) body.grid_columnconfigure(0, weight=1)
body.grid_columnconfigure(1, weight=1) route_card = HubCard(body, height=480)
route_card = HubCard(body, height=370) route_card.grid(row=0, column=0, sticky="nsew")
route_card.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
route_card.grid_propagate(False) route_card.grid_propagate(False)
ctk.CTkLabel( ctk.CTkLabel(
route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
@ -422,8 +438,10 @@ class DashboardView(ctk.CTkFrame):
self.route_diagram = _RouteDiagram(route_card) self.route_diagram = _RouteDiagram(route_card)
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6)) self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
self.realtime = HubCard(body, height=370) # Metrics still exist as data probes but are intentionally not shown on
self.realtime.grid(row=0, column=1, sticky="nsew") # Overview. The previous right rail duplicated Accounts/Status data
# and made the useful routing canvas unnecessarily narrow.
self.realtime = HubCard(self, height=1)
self.realtime.grid_propagate(False) self.realtime.grid_propagate(False)
ctk.CTkLabel( ctk.CTkLabel(
self.realtime, self.realtime,
@ -503,7 +521,7 @@ class DashboardView(ctk.CTkFrame):
cache: Dict[str, _EndpointCard], cache: Dict[str, _EndpointCard],
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]], items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
) -> None: ) -> None:
prepared = list(items)[:3] prepared = list(items)[: len(slots)]
live = {key for key, *_rest in prepared} live = {key for key, *_rest in prepared}
for key in list(cache): for key in list(cache):
if key not in live: if key not in live:
@ -552,9 +570,9 @@ class DashboardView(ctk.CTkFrame):
float(total_calls) if total_calls is not None else None, float(total_calls) if total_calls is not None else None,
) )
self.agents_metric.set_metric( self.agents_metric.set_metric(
f"{readiness.roles_ready_count}/{readiness.total_roles}", str(readiness.accounts_connected_count),
"готовые роли", f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}",
float(readiness.roles_ready_count), float(readiness.accounts_connected_count),
) )
self.latency_metric.set_metric( self.latency_metric.set_metric(
f"{latency:.0f} мс" if latency is not None else "Н", f"{latency:.0f} мс" if latency is not None else "Н",
@ -576,13 +594,16 @@ class DashboardView(ctk.CTkFrame):
provider.provider_id, provider.provider_id,
provider.provider_id, provider.provider_id,
provider.provider_name, provider.provider_name,
next( (
f"{provider.connected_count} аккаунт(а) • "
+ next(
( (
profile.preferred_models[0] profile.preferred_models[0]
for profile in snapshot.profiles_by_provider.get(provider.provider_id, []) for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models if profile.preferred_models
), ),
"Модель: Н", "модель: Н",
)
), ),
"Онлайн" if provider.online_count else "Недоступен", "Онлайн" if provider.online_count else "Недоступен",
*self._quota_measurement(snapshot, provider.provider_id), *self._quota_measurement(snapshot, provider.provider_id),
@ -601,7 +622,7 @@ class DashboardView(ctk.CTkFrame):
else: else:
self.route_diagram.orchestrator.update_node("Не назначен", "Н", False) self.route_diagram.orchestrator.update_node("Не назначен", "Н", False)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3] agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5]
self._sync_endpoint_cards( self._sync_endpoint_cards(
self.route_diagram.agent_slots, self.route_diagram.agent_slots,
self._agent_cards, self._agent_cards,
@ -619,6 +640,21 @@ class DashboardView(ctk.CTkFrame):
for agent in agents for agent in agents
), ),
) )
for agent in agents:
card = self._agent_cards.get(agent.role_id)
if card is not None:
card.set_click_action(
lambda current=agent: self.on_action(
"agent_settings",
{
"role_id": current.role_id,
"profile_id": current.assigned_profile_id,
"provider": current.provider,
},
)
if self.on_action
else None
)
left_labels: list[str] = [] left_labels: list[str] = []
for provider in providers: for provider in providers:
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share") share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
@ -641,11 +677,9 @@ class DashboardView(ctk.CTkFrame):
row.pack(fill="x") row.pack(fill="x")
self._provider_status_rows[provider.provider_id] = row self._provider_status_rows[provider.provider_id] = row
provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms") provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms")
detail = ( detail = f"{provider.online_count}/{provider.connected_count} онлайн"
f"{provider_latency:.0f} мс" if provider_latency is not None:
if provider_latency is not None detail += f"{provider_latency:.0f} мс"
else f"{provider.online_count}/{provider.connected_count}"
)
row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning") row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning")
host = dict(snapshot.metrics.get("host") or {}) host = dict(snapshot.metrics.get("host") or {})

View file

@ -27,7 +27,7 @@ class RoutingRoleWidget(HubCard):
self.meta.pack(side="left", padx=Theme.SPACE_SM) self.meta.pack(side="left", padx=Theme.SPACE_SM)
ActionButton( ActionButton(
top, top,
text="Настроить", text="Изменить цепочку →",
variant="secondary", variant="secondary",
width=90, width=90,
command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}), command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}),

View file

@ -25,6 +25,7 @@ from antigravity_provider.router.unified_health import (
STATUS_AUTH_EXPIRED, STATUS_AUTH_EXPIRED,
) )
from antigravity_provider.router.state_store import HubSnapshot from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.event_bus import ( from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_ADDED, EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_AUTH_CHANGED, EVENT_ACCOUNT_AUTH_CHANGED,
@ -38,6 +39,37 @@ from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController
def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]:
"""Persist one ordered chain using AutoAssigner while preserving other roles."""
config = load_router_config()
policy = config.roles.get(role_id)
if policy is None:
return False, f"Роль '{role_id}' не найдена"
if len(desired_chain) != len(set(desired_chain)):
return False, "Профиль не может повторяться в одной цепочке"
missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles]
if missing:
return False, f"Профиль '{missing[0]}' не найден"
original = {key: list(value.preferred_chain) for key, value in config.roles.items()}
removed = set(original[role_id]) - set(desired_chain)
affected = {role_id}
for profile_id in removed:
affected.update(key for key, chain in original.items() if profile_id in chain)
ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False)
if not ok:
return False, message
chains = {key: original[key] for key in affected}
chains[role_id] = list(desired_chain)
for target_role, chain in chains.items():
for profile_id in reversed(chain):
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
if not ok:
return False, message
return True, f"Цепочка '{role_id}' сохранена"
class AgentCardWidget(HubCard): class AgentCardWidget(HubCard):
"""Reusable agent card that updates in-place without rebuilding widgets.""" """Reusable agent card that updates in-place without rebuilding widgets."""
@ -128,6 +160,27 @@ class AgentCardWidget(HubCard):
command=self._open_menu, command=self._open_menu,
) )
self.menu_btn.pack(side="right") self.menu_btn.pack(side="right")
self._bind_settings_click(self)
def _bind_settings_click(self, widget: Any) -> None:
if isinstance(widget, ctk.CTkButton):
return
widget.bind("<Button-1>", self._open_settings, add="+")
for child in widget.winfo_children():
self._bind_settings_click(child)
def _open_settings(self, _event: Any = None) -> None:
if not self.agent_data or not self.on_action:
return
agent = self.agent_data
self.on_action(
"agent_settings",
{
"role_id": agent.role_id,
"profile_id": agent.assigned_profile_id or "",
"provider": agent.provider,
},
)
def update_agent(self, a: AgentViewModel): def update_agent(self, a: AgentViewModel):
self.agent_data = a self.agent_data = a
@ -651,11 +704,89 @@ class TeamView(ctk.CTkFrame):
font=Theme.font_micro(), font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY, text_color=Theme.TEXT_SECONDARY,
).pack(fill="x", padx=8, pady=(2, 7)) ).pack(fill="x", padx=8, pady=(2, 7))
controls = ctk.CTkFrame(card, fg_color="transparent")
controls.pack(fill="x", padx=6, pady=(0, 6))
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, -1),
).pack(side="left", padx=2)
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, 1),
).pack(side="left", padx=2)
HubButton(
controls,
text="Удалить",
variant="ghost",
width=76,
height=26,
command=lambda pid=profile_id: self._remove_chain_profile(pid),
).pack(side="right", padx=2)
if not chain: if not chain:
ctk.CTkLabel( ctk.CTkLabel(
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
).pack(anchor="w", padx=6, pady=8) ).pack(anchor="w", padx=6, pady=8)
available = [profile_id for profile_id in config.profiles if profile_id not in chain]
add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent")
add_row.pack(fill="x", pady=(8, 2))
add_menu = ctk.CTkOptionMenu(
add_row,
values=available or [""],
font=Theme.font_caption(),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
button_hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4))
HubButton(
add_row,
text="Добавить",
variant="secondary",
width=88,
command=lambda: self._add_chain_profile(add_menu.get()),
state="normal" if available else "disabled",
).pack(side="right")
def _persist_selected_chain(self, chain: List[str]) -> None:
ok, message = persist_role_chain(self.selected_role, chain)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
if ok:
self._update_inspector()
def _move_chain_profile(self, profile_id: str, direction: int) -> None:
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
return
source = chain.index(profile_id)
target = source + direction
if target < 0 or target >= len(chain):
self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING)
return
chain[source], chain[target] = chain[target], chain[source]
self._persist_selected_chain(chain)
def _remove_chain_profile(self, profile_id: str) -> None:
chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id]
self._persist_selected_chain(chain)
def _add_chain_profile(self, profile_id: str) -> None:
if not profile_id or profile_id == "":
return
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
chain.append(profile_id)
self._persist_selected_chain(chain)
def _on_runtime_event(self, name: str, data: Any) -> None: def _on_runtime_event(self, name: str, data: Any) -> None:
# EventBus may publish from a worker. Tk mutation is always marshalled. # EventBus may publish from a worker. Tk mutation is always marshalled.
try: try:

View file

@ -187,13 +187,30 @@ def test_quota_snapshot_model_availability():
def test_antigravity_separate_claude_and_gemini_buckets(): def test_antigravity_separate_claude_and_gemini_buckets():
service = AccountQuotaService() service = AccountQuotaService()
snap = service._collect_antigravity_quota("ag-w1", {"tokens": {}}) response = MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(
{
"models": {
"claude-sonnet-4-6": {
"quotaInfo": {"remainingFraction": 0.42, "resetTime": "2026-08-23T00:00:00Z"}
},
"gemini-3.7-flash": {
"quotaInfo": {"remainingFraction": 0.87, "resetTime": "2026-08-23T00:00:00Z"}
},
}
}
).encode()
with patch("antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"), patch(
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
):
snap = service._collect_antigravity_quota(
"ag-w1", {"tokens": {"access_token": "access-token"}}
)
bucket_ids = [b.id for b in snap.buckets] bucket_ids = [b.id for b in snap.buckets]
assert "antigravity.claude.5h" in bucket_ids assert "antigravity.claude.model_pool" in bucket_ids
assert "antigravity.claude.weekly" in bucket_ids assert "antigravity.gemini.model_pool" in bucket_ids
assert "antigravity.gemini.5h" in bucket_ids assert snap.source == "provider_api"
assert "antigravity.gemini.weekly" in bucket_ids
# Claude bucket and Gemini bucket are independent # Claude bucket and Gemini bucket are independent
b_c = snap.get_bucket_for_model("claude-3-7-sonnet") b_c = snap.get_bucket_for_model("claude-3-7-sonnet")
@ -201,6 +218,8 @@ def test_antigravity_separate_claude_and_gemini_buckets():
assert b_c is not None and b_c.model_family == "claude" assert b_c is not None and b_c.model_family == "claude"
assert b_g is not None and b_g.model_family == "gemini" assert b_g is not None and b_g.model_family == "gemini"
assert b_c.remaining_percent == 42.0
assert b_g.remaining_percent == 87.0
def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path): def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path):

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
import time import time
import pytest import pytest
@ -21,6 +22,7 @@ from antigravity_provider.router.unified_health import (
AgentViewModel, AgentViewModel,
PipelineNode, PipelineNode,
ProfileViewModel, ProfileViewModel,
ProviderSummary,
RolePipeline, RolePipeline,
SystemReadiness, SystemReadiness,
) )
@ -215,6 +217,54 @@ def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
view.destroy() view.destroy()
@pytest.mark.ui
def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_root) -> None:
profiles = [
replace(
_profile(),
profile_id=f"ag-{index}",
account_identity=f"user{index}@example.test",
email=f"user{index}@example.test",
)
for index in range(6)
]
readiness = replace(_readiness(), accounts_connected_count=6, total_accounts=6)
provider = ProviderSummary(
provider_id="antigravity",
provider_name="Google Antigravity",
total_slots=10,
connected_count=6,
online_count=6,
auth_required_count=0,
quota_exhausted_count=0,
cold_spare_count=0,
discovered_models=["gemini-3.7-flash"],
last_refresh_at="12:00:00",
)
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={"antigravity": profiles},
all_profiles={profile.profile_id: profile for profile in profiles},
readiness=readiness,
agents=[],
providers=[provider],
routing={},
quotas={},
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
assert view.agents_metric.val_label.cget("text") == "6"
assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text")
assert "6/6 онлайн" in view._provider_status_rows["antigravity"].detail.cget("text")
finally:
view.destroy()
@pytest.mark.unit @pytest.mark.unit
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None: def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
snapshot = QuotaSnapshot( snapshot = QuotaSnapshot(

View file

@ -15,6 +15,7 @@ import customtkinter as ctk
from antigravity_provider.router.ui.components import AccountCardWidget from antigravity_provider.router.ui.components import AccountCardWidget
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router import hermes_hub_app as app_module
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@ -124,3 +125,23 @@ def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS] assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS]
finally: finally:
card.destroy() card.destroy()
@pytest.mark.ui
def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None:
policy = SimpleNamespace(preferred_chain=[])
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"orchestrator": policy}))
monkeypatch.setattr(
app_module.AutoAssigner,
"assign_profile_to_role",
lambda *_args, **_kwargs: (False, "Профиль не найден"),
)
ui_root._show_account_action_result = lambda *_args: None
modal = app_module.HermesHubApp._open_assign_role_modal(ui_root, "missing", "Claude")
try:
modal.save_button.invoke()
modal.update_idletasks()
assert modal.winfo_exists()
assert "Профиль не найден" in modal.result_label.cget("text")
finally:
modal.destroy()

View file

@ -125,6 +125,7 @@ def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root)
ui_root.update_idletasks() ui_root.update_idletasks()
before = view.render_stats() before = view.render_stats()
card_ids = {key: id(card) for key, card in view._cards.items()} card_ids = {key: id(card) for key, card in view._cards.items()}
assert all(card.compact for card in view._cards.values())
view.update_data(_snapshot(changed_remaining=42.0)) view.update_data(_snapshot(changed_remaining=42.0))
ui_root.update_idletasks() ui_root.update_idletasks()

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@ -8,6 +9,7 @@ pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import routing_graph as graph_module from antigravity_provider.router.ui import routing_graph as graph_module
from antigravity_provider.router.ui import add_account_wizard as wizard_module from antigravity_provider.router.ui import add_account_wizard as wizard_module
from antigravity_provider.router.ui.views import team_view as team_module
from antigravity_provider.router import hermes_hub_app as app_module from antigravity_provider.router import hermes_hub_app as app_module
from antigravity_provider.router.ui.routing_graph import ( from antigravity_provider.router.ui.routing_graph import (
GraphEdge, GraphEdge,
@ -182,3 +184,139 @@ def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
assert ("clear", "ag-orch-fallback") in calls assert ("clear", "ag-orch-fallback") in calls
assert any(item[0] == "log" for item in calls) assert any(item[0] == "log" for item in calls)
assert calls[-1] == ("destroy",) assert calls[-1] == ("destroy",)
def test_wizard_stops_when_provider_has_no_real_free_slot(monkeypatch):
calls = []
monkeypatch.setattr(wizard_module.AutoAssigner, "find_free_slot", lambda _provider: None)
fake = SimpleNamespace(
selected_provider="grok",
target_slot="old-value",
_clear_body=lambda: calls.append("clear"),
title_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
_show_no_free_slot=lambda: calls.append("no-slot"),
)
wizard_module.AddAccountWizard._show_step_2_auth(fake)
assert fake.target_slot == ""
assert calls == ["clear", "no-slot"]
def test_wizard_finish_without_slot_shows_error_and_does_not_assign(monkeypatch):
updates = []
monkeypatch.setattr(
wizard_module,
"ensure_profile_in_routing",
lambda _profile: pytest.fail("an invented or empty slot must not be assigned"),
)
fake = SimpleNamespace(
target_slot="",
finish_status_lbl=SimpleNamespace(configure=lambda **kwargs: updates.append(kwargs)),
)
wizard_module.AddAccountWizard._finish(fake)
assert "свободный слот" in updates[-1]["text"]
def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch):
config = SimpleNamespace(
profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
roles={
"orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]),
"reviewer": SimpleNamespace(preferred_chain=["b"]),
},
)
calls = []
def assign(profile_id, role_id, is_primary=True):
calls.append((profile_id, role_id, is_primary))
if role_id == "spare":
for policy in config.roles.values():
policy.preferred_chain = [item for item in policy.preferred_chain if item != profile_id]
return True, "spare"
chain = config.roles[role_id].preferred_chain
chain = [item for item in chain if item != profile_id]
if is_primary:
chain.insert(0, profile_id)
else:
chain.append(profile_id)
config.roles[role_id].preferred_chain = chain
return True, "assigned"
monkeypatch.setattr(team_module, "load_router_config", lambda: config)
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"])
assert ok
assert config.roles["orchestrator"].preferred_chain == ["c", "a"]
assert config.roles["reviewer"].preferred_chain == ["b"]
assert ("b", "spare", False) in calls
def test_account_action_result_is_sent_to_originating_card():
calls = []
accounts = SimpleNamespace(
show_action_result=lambda profile_id, message, success: calls.append(
("card", profile_id, message, success)
)
)
fake = SimpleNamespace(
_views={"accounts": accounts},
_show_toast=lambda message: calls.append(("toast", message)),
)
app_module.HermesHubApp._show_account_action_result(fake, "profile-1", "Не найден", False)
assert calls[0] == ("card", "profile-1", "Не найден", False)
assert calls[1][0] == "toast"
def test_device_code_step_contains_numbered_instructions_and_copy_actions():
source = Path(wizard_module.__file__).read_text(encoding="utf-8")
assert "1. Откройте ссылку" in source
assert "2. Введите на странице код:" in source
assert "3. Подтвердите доступ" in source
assert source.count('text="Копировать ссылку"') == 2
assert source.count('text="📋 Копировать код"') == 2
def test_grok_slot_is_registered_before_role_assignment(monkeypatch):
config = SimpleNamespace(profiles={})
saved = []
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
monkeypatch.setattr(
"antigravity_provider.router.auto_assigner.load_router_config",
lambda: config,
)
monkeypatch.setattr(
"antigravity_provider.router.auto_assigner.save_router_config",
lambda current: saved.append(current) or True,
)
ok, _message = wizard_module.AutoAssigner.ensure_profile_definition("grok", "grok-orch")
assert ok
assert config.profiles["grok-orch"].provider == "grok"
assert config.profiles["grok-orch"].preferred_models[0] == "grok-3"
assert saved == [config]
def test_opencode_paste_targets_entry_and_reports_success():
calls = []
entry = SimpleNamespace(
clipboard_get=lambda: " opencode-token-123 ",
delete=lambda *_args: calls.append("delete"),
insert=lambda *_args: calls.append(("insert", _args[-1])),
focus_set=lambda: calls.append("focus"),
icursor=lambda *_args: calls.append("cursor"),
)
status = SimpleNamespace(configure=lambda **kwargs: calls.append(("status", kwargs["text"])))
fake = SimpleNamespace(key_entry=entry, key_status_lbl=status)
wizard_module.AddAccountWizard._paste_into_entry(fake, entry)
assert ("insert", "opencode-token-123") in calls
assert ("status", "✓ Ключ вставлен. Нажмите «Проверить и продолжить».") in calls