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
@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
def recommend_assignment(provider: str) -> Tuple[str, str, str]:
"""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:
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.auto_assigner import AutoAssigner
from antigravity_provider.router.adapters import get_adapter
@ -61,7 +61,7 @@ from antigravity_provider.router.unified_health import (
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.accounts_view import AccountsView
from antigravity_provider.router.ui.views.providers_view import ProvidersView
@ -231,6 +231,18 @@ class HermesHubApp(ctk.CTk):
pass
self.after(50, self._refresh_data)
self.after(800, self._refresh_quotas_on_startup)
def _refresh_quotas_on_startup(self) -> None:
"""Populate measured quota cards immediately instead of after the 5-minute scheduler tick."""
if self._shutting_down:
return
try:
from antigravity_provider.router.scheduler import HermesRefreshScheduler
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
except Exception as exc:
logger.warning("Initial quota refresh could not start: %s", exc)
def _build_layout(self):
# ── Sidebar (Left) ──
@ -263,8 +275,8 @@ class HermesHubApp(ctk.CTk):
("team", "Команда", "team"),
("accounts", "Аккаунты", "accounts"),
("routing", "Маршрутизация", "routing"),
("providers", "Провайдеры", "providers"),
("quotas", "Квоты и лимиты", "quotas"),
("providers", "Модели и провайдеры", "providers"),
("quotas", "Сводка лимитов", "quotas"),
("analytics", "Аналитика", "analytics"),
("health", "Состояние", "health"),
("logs", "Журнал событий", "logs"),
@ -595,28 +607,33 @@ class HermesHubApp(ctk.CTk):
if action == "set_main":
self._run_in_thread(
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":
self._run_in_thread(
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":
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(
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":
self._open_add_account_wizard()
elif action == "delete_credentials":
self._run_in_thread(
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":
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":
self._show_toast("⚡ Автоматическое распределение ролей...")
self._run_in_thread(
@ -639,11 +656,7 @@ class HermesHubApp(ctk.CTk):
)
elif action == "edit_route":
role_id = data.get("role_id", "")
self._show_view("team")
team = self._views.get("team")
if team and hasattr(team, "focus_role"):
team.focus_role(role_id)
self._show_toast(f"Настройка цепочки роли: {role_id}")
self._open_route_editor_modal(role_id)
elif action == "open_routing":
self._show_view("routing")
routing = self._views.get("routing")
@ -724,6 +737,18 @@ class HermesHubApp(ctk.CTk):
hover_color=Theme.ACCENT_HOVER,
).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():
chosen = role_var.get()
ok, msg = AutoAssigner.assign_profile_to_role(
@ -731,12 +756,279 @@ class HermesHubApp(ctk.CTk):
chosen,
is_primary=primary_var.get() and chosen != "spare",
)
modal.destroy()
self._show_toast(f"{msg}" if ok else f"{msg}")
self._refresh_data()
result_label.configure(
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()
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):
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._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"):
msg = f"✓ Профиль готов | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s"
msg = f"Профиль готов • модель: {result.get('model')} • время: {result.get('duration_sec')} с"
else:
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_toast(msg)
msg = f"Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_account_action_result(profile_id, msg, bool(result.get("success")))
def _apply_theme(self, scheme: str) -> None:
"""Apply all palette tokens by rebuilding presentation widgets in place."""

View file

@ -15,6 +15,8 @@ import logging
import os
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
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]:
"""Fetch quota for all configured profiles across all providers."""
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:
# Check slots
from antigravity_provider.router.auto_assigner import AutoAssigner
slots = AutoAssigner.PRESET_SLOTS.get(prov, [])
for slot_id in slots:
auth = ProfileAuthManager.load_profile_auth(prov, slot_id)
if auth:
snap = self.fetch_account_quota(prov, slot_id, force=force)
results[f"{prov}:{slot_id}"] = snap
for profile_id, profile in load_router_config().profiles.items():
auth = ProfileAuthManager.load_profile_auth(profile.provider, profile_id)
if not auth:
continue
snap = self.fetch_account_quota(profile.provider, profile_id, force=force)
results[f"{profile.provider}:{profile_id}"] = snap
return results
# ─────────────────────────────────────────────────────────────
@ -313,60 +312,99 @@ class AccountQuotaService:
# ─────────────────────────────────────────────────────────────
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."""
now = _utc_now()
claude_reset_5h = now + timedelta(hours=5)
gemini_reset_5h = now + timedelta(hours=5)
weekly_reset = now + timedelta(days=7)
"""Read measured per-model capacity from the official Cloud Code endpoint.
# Build separate capacity buckets
b_claude_5h = QuotaBucket(
id="antigravity.claude.5h",
display_name="Claude 5h",
model_family="claude",
used_percent=None,
remaining_percent=None,
period="5h",
reset_at=claude_reset_5h,
status="unknown",
)
b_claude_weekly = QuotaBucket(
id="antigravity.claude.weekly",
display_name="Claude Weekly",
model_family="claude",
used_percent=None,
remaining_percent=None,
period="7d",
reset_at=weekly_reset,
status="unknown",
)
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",
)
The endpoint exposes one live capacity pool per model, not artificial
``5h``/``weekly`` pairs. We therefore show the minimum remaining value
in each model family. This keeps the compact card useful while never
presenting an invented period or percentage.
"""
from antigravity_provider.cloudcode import antigravity_user_agent, load_or_onboard_project
from antigravity_provider.oauth import refresh_access_token
now = _utc_now()
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",
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8") or "{}")
try:
payload = _fetch(str(access_token))
except urllib.error.HTTPError as exc:
refresh_token = token_data.get("refresh_token") or token_data.get("refresh")
if exc.code != 401 or not refresh_token:
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",
)
)
if not buckets:
raise RuntimeError("Cloud Code не вернул измеряемые квоты Claude или Gemini")
return QuotaSnapshot(
account_id=profile_id,
provider="antigravity",
buckets=[b_claude_5h, b_claude_weekly, b_gemini_5h, b_gemini_weekly],
buckets=buckets,
fetched_at=now,
source="baseline",
source="provider_api",
)
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.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
self.target_slot = (
AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
)
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()
@ -236,6 +237,36 @@ class AddAccountWizard(HubModal):
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",
}
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
# ─────────────────────────────────────────────────────────────
@ -484,22 +515,10 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
auth_card,
text=(
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице OpenAI. "
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется."
),
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))
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))
@ -516,9 +535,9 @@ class AddAccountWizard(HubModal):
HubButton(
url_row,
text="📋",
text="Копировать ссылку",
variant="secondary",
width=40,
width=130,
height=32,
command=self._copy_codex_url,
).pack(side="right")
@ -528,7 +547,7 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
code_card,
text="Код подтверждения:",
text="2. Введите на странице код:",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8))
@ -536,7 +555,7 @@ class AddAccountWizard(HubModal):
self.codex_code_lbl = ctk.CTkLabel(
code_card,
text="...",
font=Theme.font_mono_bold(),
font=("Consolas", 18, "bold"),
text_color=Theme.ACCENT,
)
self.codex_code_lbl.pack(side="left", padx=(0, 8))
@ -561,6 +580,13 @@ class AddAccountWizard(HubModal):
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))
@ -779,9 +805,9 @@ class AddAccountWizard(HubModal):
HubButton(
url_row,
text="📋",
text="Копировать ссылку",
variant="secondary",
width=40,
width=130,
height=32,
command=self._copy_claude_url,
).pack(side="right")
@ -940,22 +966,10 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
auth_card,
text=(
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице xAI. "
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется."
),
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))
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))
@ -984,7 +998,7 @@ class AddAccountWizard(HubModal):
ctk.CTkLabel(
code_card,
text="Код подтверждения:",
text="2. Введите на странице код:",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8))
@ -992,7 +1006,7 @@ class AddAccountWizard(HubModal):
self.grok_code_lbl = ctk.CTkLabel(
code_card,
text="...",
font=Theme.font_mono_bold(),
font=("Consolas", 18, "bold"),
text_color=Theme.PROVIDER_GROK,
)
self.grok_code_lbl.pack(side="left", padx=(0, 8))
@ -1017,6 +1031,13 @@ class AddAccountWizard(HubModal):
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))
@ -1277,13 +1298,14 @@ class AddAccountWizard(HubModal):
entry_row,
placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."),
font=Theme.font_mono(),
show="*",
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,
@ -1359,12 +1381,19 @@ class AddAccountWizard(HubModal):
def _paste_into_entry(self, entry_widget: HubEntry):
try:
content = self.clipboard_get().strip()
content = entry_widget.clipboard_get().strip()
if content:
entry_widget.delete(0, "end")
entry_widget.insert(0, content)
except Exception:
pass
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}")
# ═══════════════════════════════════════════════════════════════
# STEP 3: Validation & Identity
@ -1512,6 +1541,19 @@ class AddAccountWizard(HubModal):
).pack(side="right", padx=10, pady=10)
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
# repaired configs may not, so completion makes that invariant explicit
# without reordering a slot that is already assigned.

View file

@ -888,6 +888,31 @@ class AccountCardWidget(HubCard):
self.status = StatusBadge(self, status)
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.pack(fill="x")
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.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] = {}
management = ctk.CTkFrame(self.actions, fg_color="transparent")
management.pack(fill="x", pady=(0, Theme.SPACE_XS))
@ -944,8 +978,19 @@ class AccountCardWidget(HubCard):
def _trigger(self, action: str) -> None:
if self.on_action and self.profile_model is not None:
self.set_action_feedback("Выполняется…", None)
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:
self.set_compact(not self.compact)
@ -954,7 +999,11 @@ class AccountCardWidget(HubCard):
self.toggle.configure(text="" if compact else "")
if compact:
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:
self.compact_quota.pack_forget()
self.compact_actions.pack_forget()
self.details.pack(fill="x")
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)
buckets = list(getattr(snapshot, "buckets", None) or [])
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()
for bucket in buckets:
key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket"))

View file

@ -203,6 +203,7 @@ class AccountsView(ctk.CTkFrame):
return
self._snapshot = snapshot
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):
if profile_id not in live_ids:
self._cards.pop(profile_id).destroy()
@ -220,11 +221,15 @@ class AccountsView(ctk.CTkFrame):
profile.profile_id,
AccountCardWidget.resolve_identity(profile),
profile.provider_display_name,
compact=len(live_ids) > 1,
on_action=self._emit,
)
self._cards[profile.profile_id] = card
self.cards_created += 1
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()
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_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.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(
self,
@ -162,16 +177,16 @@ class _RouteDiagram(ctk.CTkFrame):
"""Responsive diagram with smooth connections behind native widgets."""
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.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
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.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):
slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30)
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.place(relx=0.51, rely=0.46, anchor="center")
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
@ -184,12 +199,12 @@ class _RouteDiagram(ctk.CTkFrame):
)
self.context_status.pack()
self._left_labels = ["", "", ""]
self._right_labels = ["", "", ""]
self._right_labels = ["", "", "", "", ""]
self.bind("<Configure>", self._redraw)
def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = (left + ["", "", ""])[:3]
self._right_labels = (right + ["", "", ""])[:3]
self._right_labels = (right + ["", "", "", "", ""])[:5]
self._redraw()
def _redraw(self, _event: Any = None) -> None:
@ -197,8 +212,8 @@ class _RouteDiagram(ctk.CTkFrame):
self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71
ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
for index, y_pos in enumerate(ys):
left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
for index, y_pos in enumerate(left_ys):
self.canvas.create_line(
left_x,
y_pos,
@ -223,6 +238,8 @@ class _RouteDiagram(ctk.CTkFrame):
anchor="w",
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(
center_x + 48,
center_y,
@ -401,7 +418,7 @@ class DashboardView(ctk.CTkFrame):
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения")
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.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
for index, metric in enumerate(
@ -411,10 +428,9 @@ class DashboardView(ctk.CTkFrame):
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x")
body.grid_columnconfigure(0, weight=4)
body.grid_columnconfigure(1, weight=1)
route_card = HubCard(body, height=370)
route_card.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
body.grid_columnconfigure(0, weight=1)
route_card = HubCard(body, height=480)
route_card.grid(row=0, column=0, sticky="nsew")
route_card.grid_propagate(False)
ctk.CTkLabel(
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.pack(fill="both", expand=True, padx=6, pady=(2, 6))
self.realtime = HubCard(body, height=370)
self.realtime.grid(row=0, column=1, sticky="nsew")
# Metrics still exist as data probes but are intentionally not shown on
# 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)
ctk.CTkLabel(
self.realtime,
@ -503,7 +521,7 @@ class DashboardView(ctk.CTkFrame):
cache: Dict[str, _EndpointCard],
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
) -> None:
prepared = list(items)[:3]
prepared = list(items)[: len(slots)]
live = {key for key, *_rest in prepared}
for key in list(cache):
if key not in live:
@ -552,9 +570,9 @@ class DashboardView(ctk.CTkFrame):
float(total_calls) if total_calls is not None else None,
)
self.agents_metric.set_metric(
f"{readiness.roles_ready_count}/{readiness.total_roles}",
"готовые роли",
float(readiness.roles_ready_count),
str(readiness.accounts_connected_count),
f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}",
float(readiness.accounts_connected_count),
)
self.latency_metric.set_metric(
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_name,
next(
(
profile.preferred_models[0]
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"Модель: Н",
(
f"{provider.connected_count} аккаунт(а) • "
+ next(
(
profile.preferred_models[0]
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"модель: Н",
)
),
"Онлайн" if provider.online_count else "Недоступен",
*self._quota_measurement(snapshot, provider.provider_id),
@ -601,7 +622,7 @@ class DashboardView(ctk.CTkFrame):
else:
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.route_diagram.agent_slots,
self._agent_cards,
@ -619,6 +640,21 @@ class DashboardView(ctk.CTkFrame):
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] = []
for provider in providers:
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
@ -641,11 +677,9 @@ class DashboardView(ctk.CTkFrame):
row.pack(fill="x")
self._provider_status_rows[provider.provider_id] = row
provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms")
detail = (
f"{provider_latency:.0f} мс"
if provider_latency is not None
else f"{provider.online_count}/{provider.connected_count}"
)
detail = f"{provider.online_count}/{provider.connected_count} онлайн"
if provider_latency is not None:
detail += f"{provider_latency:.0f} мс"
row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning")
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)
ActionButton(
top,
text="Настроить",
text="Изменить цепочку →",
variant="secondary",
width=90,
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,
)
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_ADDED,
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
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):
"""Reusable agent card that updates in-place without rebuilding widgets."""
@ -128,6 +160,27 @@ class AgentCardWidget(HubCard):
command=self._open_menu,
)
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):
self.agent_data = a
@ -651,11 +704,89 @@ class TeamView(ctk.CTkFrame):
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
).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:
ctk.CTkLabel(
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
).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:
# EventBus may publish from a worker. Tk mutation is always marshalled.
try:

View file

@ -187,13 +187,30 @@ def test_quota_snapshot_model_availability():
def test_antigravity_separate_claude_and_gemini_buckets():
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]
assert "antigravity.claude.5h" in bucket_ids
assert "antigravity.claude.weekly" in bucket_ids
assert "antigravity.gemini.5h" in bucket_ids
assert "antigravity.gemini.weekly" in bucket_ids
assert "antigravity.claude.model_pool" in bucket_ids
assert "antigravity.gemini.model_pool" in bucket_ids
assert snap.source == "provider_api"
# Claude bucket and Gemini bucket are independent
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_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):

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import replace
import time
import pytest
@ -21,6 +22,7 @@ from antigravity_provider.router.unified_health import (
AgentViewModel,
PipelineNode,
ProfileViewModel,
ProviderSummary,
RolePipeline,
SystemReadiness,
)
@ -215,6 +217,54 @@ def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
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
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
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.theme import Theme
from antigravity_provider.router import hermes_hub_app as app_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]
finally:
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()
before = view.render_stats()
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))
ui_root.update_idletasks()

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
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 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.ui.routing_graph import (
GraphEdge,
@ -182,3 +184,139 @@ def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
assert ("clear", "ag-orch-fallback") in calls
assert any(item[0] == "log" for item in calls)
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