Merge codex/usability-fixes into review/a8 (A8 + B6/B7)
Сводит две работы: A8 (Antigravity — запуск, развёртывание, самопроверка)
и codex/usability-fixes (B6 граф маршрутизации + B7 дефекты живого прогона,
плюс живой сбор квот).
Проверено исполнением на реальных аккаунтах владельца: квоты Antigravity
теперь приходят от провайдера (source=provider_api) по всем шести
авторизованным профилям с разными числами — ag-w2 показывает 37.4%
остатка недельного пула Claude/GPT. OpenCode Go честно отдаёт None
с причиной.
Разрешение конфликтов:
1. do_test_profile — оба агента чинили P0-3 по-разному. Сохранены обе
правки: проверка просроченной авторизации (A8) поверх локальной
проверки runtime без вызова модели (Codex).
2. _finish в мастере — взята содержательная версия Codex (проверка слота,
создание определения профиля, внесение в маршрутизацию, сброс
cooldown), но её хвост обёрнут так, чтобы сбой в журналировании или
on_complete не оставлял окно открытым. Регрессия 7090c8a закрыта
тестом и продолжает проходить.
Исправлено при слиянии: A8 проверял status.get("expired"), тогда как ключ
называется is_expired. Проверка была мертва изначально — её прикрывал
контроль в адаптере, и это вскрылось только когда Codex убрал вызов
адаптера из «Теста»: протухший аккаунт получал зелёную галочку.
Тесты: 288 passed, 2 skipped, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b625b0f3e5
21 changed files with 2732 additions and 361 deletions
BIN
artifacts/b6-live-overview-front.png
Normal file
BIN
artifacts/b6-live-overview-front.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
artifacts/b6-live-team-graph.png
Normal file
BIN
artifacts/b6-live-team-graph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
|
|
@ -265,12 +265,14 @@ class QuotaSnapshot:
|
|||
return f"Обновлено: {hrs} ч назад"
|
||||
|
||||
def get_bucket_for_model(self, model_or_family: str) -> Optional[QuotaBucket]:
|
||||
"""Find the relevant quota bucket for a given model or model family."""
|
||||
"""Find the most constraining quota bucket for a model family."""
|
||||
target = model_or_family.lower()
|
||||
# Direct family match
|
||||
for b in self.buckets:
|
||||
if b.model_family and b.model_family.lower() in target:
|
||||
return b
|
||||
matches = [b for b in self.buckets if b.model_family and b.model_family.lower() in target]
|
||||
if matches:
|
||||
measured = [b for b in matches if b.remaining_percent is not None]
|
||||
if measured:
|
||||
return min(measured, key=lambda bucket: float(bucket.remaining_percent or 0.0))
|
||||
return matches[0]
|
||||
# Fallback to first available bucket
|
||||
return self.buckets[0] if self.buckets else None
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,48 @@ class AutoAssigner:
|
|||
|
||||
return 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)."""
|
||||
|
|
|
|||
|
|
@ -273,6 +273,36 @@ class HealthTracker:
|
|||
|
||||
self._save_state()
|
||||
|
||||
def reconcile_measured_quota(self, profile_id: str, remaining_by_family: Dict[str, float]) -> bool:
|
||||
"""Clear stale quota-exhausted flags when the provider reports live capacity.
|
||||
|
||||
A successful quota read is authoritative for quota exhaustion, but it
|
||||
must not erase unrelated authentication or runtime failures.
|
||||
"""
|
||||
measured = {family: float(value) for family, value in remaining_by_family.items()}
|
||||
if not measured:
|
||||
return False
|
||||
with self._lock:
|
||||
record = self.get_or_create(profile_id)
|
||||
changed = False
|
||||
for family, remaining in measured.items():
|
||||
family_record = record.families.get(family)
|
||||
if remaining > 0 and family_record and family_record.state == QUOTA_EXHAUSTED:
|
||||
family_record.state = HEALTHY
|
||||
family_record.reset_at = None
|
||||
family_record.reason = None
|
||||
family_record.last_error = None
|
||||
family_record.simulated = False
|
||||
changed = True
|
||||
if all(value > 0 for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED:
|
||||
record.overall_state = HEALTHY
|
||||
record.last_error = None
|
||||
record.simulated = False
|
||||
changed = True
|
||||
if changed:
|
||||
self._save_state()
|
||||
return changed
|
||||
|
||||
def mark_quota_exhausted(
|
||||
self,
|
||||
profile_id: str,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -75,6 +75,24 @@ from antigravity_provider.router.ui.views.quotas_view import QuotasView
|
|||
|
||||
logger = logging.getLogger("hermes.hub.gui")
|
||||
|
||||
AGENT_MODEL_OPTIONS = {
|
||||
"antigravity": [
|
||||
# User-facing logical IDs. The provider layer maps Gemini 3.1 Pro
|
||||
# to the current low/high wire variants according to reasoning effort.
|
||||
"gemini-3.1-pro",
|
||||
"gemini-2.5-pro",
|
||||
"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 _load_saved_theme() -> str:
|
||||
settings_file = paths.get_hermes_home() / "hub_settings.json"
|
||||
|
|
@ -109,7 +127,7 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
|||
|
||||
|
||||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||
"""Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers."""
|
||||
"""Check local profile readiness without inference, OAuth, or a browser."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
|
|
@ -119,25 +137,36 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
|||
if not status.get("authenticated"):
|
||||
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
|
||||
|
||||
if status.get("expired"):
|
||||
# Ключ называется is_expired; часть провайдеров сообщает о просрочке только
|
||||
# через status == "EXPIRED". Проверяем все формы: зелёная галочка на
|
||||
# протухшем аккаунте — это ложь пользователю, а не мелкая неточность.
|
||||
if status.get("is_expired") or status.get("expired") or status.get("status") == "EXPIRED":
|
||||
return {"success": False, "error": "Авторизация истекла, требуется повторный вход."}
|
||||
|
||||
adapter = get_adapter(pcfg.provider)
|
||||
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = adapter.invoke(
|
||||
pcfg,
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
|
||||
"temperature": 0.1,
|
||||
},
|
||||
)
|
||||
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
|
||||
if not auth_data:
|
||||
return {"success": False, "error": "Сохранённые данные авторизации не найдены"}
|
||||
adapter = get_adapter(pcfg.provider)
|
||||
runtime_ready = adapter.health_check(pcfg)
|
||||
el = round(time.time() - t0, 2)
|
||||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success")
|
||||
return {"success": True, "model": model, "duration_sec": el, "response": content[:120]}
|
||||
if not runtime_ready:
|
||||
return {
|
||||
"success": False,
|
||||
"duration_sec": el,
|
||||
"error": "Локальный runtime провайдера недоступен; повторная авторизация не запускалась",
|
||||
}
|
||||
EventLogService.get().log(
|
||||
"system", f"Локальная проверка профиля {profile_id} ({model}) пройдена за {el}s.", level="success"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"model": model,
|
||||
"duration_sec": el,
|
||||
"response": "Авторизация сохранена; runtime провайдера доступен",
|
||||
}
|
||||
except Exception as e:
|
||||
EventLogService.get().log("system", f"Ошибка теста {profile_id} ({model}): {e}", level="error")
|
||||
return {"success": False, "model": model, "duration_sec": round(time.time() - t0, 2), "error": str(e)}
|
||||
|
|
@ -226,6 +255,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) ──
|
||||
|
|
@ -258,8 +299,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"),
|
||||
|
|
@ -341,6 +382,8 @@ class HermesHubApp(ctk.CTk):
|
|||
text_color=Theme.STATUS_HEALTHY,
|
||||
)
|
||||
self.status_left.pack(side="left", padx=Theme.SPACE_LG)
|
||||
self.status_left.configure(cursor="hand2")
|
||||
self.status_left.bind("<Button-1>", lambda _event: self._show_view("health"), add="+")
|
||||
|
||||
self.global_search = ctk.CTkEntry(
|
||||
self.statusbar,
|
||||
|
|
@ -545,7 +588,7 @@ class HermesHubApp(ctk.CTk):
|
|||
|
||||
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
|
||||
self.status_left.configure(
|
||||
text=f"● {readiness.title_ru}",
|
||||
text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}",
|
||||
text_color=Theme.STATUS_HEALTHY
|
||||
if readiness.state == "healthy"
|
||||
else Theme.STATUS_WARNING
|
||||
|
|
@ -590,28 +633,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(
|
||||
|
|
@ -633,7 +681,13 @@ class HermesHubApp(ctk.CTk):
|
|||
on_complete=lambda: self.after(0, self._refresh_data),
|
||||
)
|
||||
elif action == "edit_route":
|
||||
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.")
|
||||
role_id = data.get("role_id", "")
|
||||
self._open_route_editor_modal(role_id)
|
||||
elif action == "open_routing":
|
||||
self._show_view("routing")
|
||||
routing = self._views.get("routing")
|
||||
if routing and hasattr(routing, "focus_role"):
|
||||
routing.focus_role(data.get("role_id", ""))
|
||||
elif action == "save_settings":
|
||||
|
||||
def _settings_saved(result: Tuple[bool, str]) -> None:
|
||||
|
|
@ -670,14 +724,19 @@ class HermesHubApp(ctk.CTk):
|
|||
justify="left",
|
||||
).pack(anchor="w", pady=(0, 12))
|
||||
|
||||
role_var = ctk.StringVar(value="orchestrator")
|
||||
config = load_router_config()
|
||||
current_role = next(
|
||||
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
|
||||
"orchestrator",
|
||||
)
|
||||
role_var = ctk.StringVar(value=current_role)
|
||||
roles = [
|
||||
("orchestrator", "👑 Главный оркестратор"),
|
||||
("coder", "💻 Кодер (Code Generation)"),
|
||||
("coder-primary", "💻 Основной кодер"),
|
||||
("coder-secondary", "💻 Резервный кодер"),
|
||||
("reviewer", "🔍 Ревьюер (Code Review)"),
|
||||
("researcher", "🌐 Исследователь (Search / Docs)"),
|
||||
("tester", "🧪 Тестировщик (Deterministic Tests)"),
|
||||
("general", "⚡ Агент общего назначения (Subagent)"),
|
||||
("research", "🌐 Исследователь (Search / Docs)"),
|
||||
("fast", "⚡ Быстрый агент"),
|
||||
("spare", "🛡️ Резерв (Spare)"),
|
||||
]
|
||||
|
||||
|
|
@ -693,15 +752,297 @@ class HermesHubApp(ctk.CTk):
|
|||
hover_color=Theme.ACCENT_HOVER,
|
||||
).pack(anchor="w", padx=8, pady=3)
|
||||
|
||||
primary_var = ctk.BooleanVar(value=True)
|
||||
ctk.CTkCheckBox(
|
||||
modal.body,
|
||||
text="Сделать основным в выбранной цепочке",
|
||||
variable=primary_var,
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
fg_color=Theme.ACCENT,
|
||||
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(profile_id, chosen, is_primary=(chosen != "spare"))
|
||||
modal.destroy()
|
||||
self._show_toast(f"✅ {msg}" if ok else f"❌ {msg}")
|
||||
self._refresh_data()
|
||||
ok, msg = AutoAssigner.assign_profile_to_role(
|
||||
profile_id,
|
||||
chosen,
|
||||
is_primary=primary_var.get() and chosen != "spare",
|
||||
)
|
||||
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))
|
||||
|
||||
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 + AGENT_MODEL_OPTIONS.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)
|
||||
|
|
@ -710,12 +1051,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."""
|
||||
|
|
@ -768,7 +1116,7 @@ class HermesHubApp(ctk.CTk):
|
|||
|
||||
readiness = HubStateStore.get().get_snapshot().readiness
|
||||
self.status_left.configure(
|
||||
text=f"● {readiness.title_ru}",
|
||||
text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}",
|
||||
text_color=Theme.STATUS_HEALTHY
|
||||
if readiness.state == "healthy"
|
||||
else Theme.STATUS_WARNING
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -148,10 +150,31 @@ class AccountQuotaService:
|
|||
except Exception as e:
|
||||
logger.warning("Error fetching quota for %s/%s: %s", provider, profile_id, e)
|
||||
snap = self._generate_baseline_snapshot(provider, profile_id)
|
||||
status = getattr(e, "status", None)
|
||||
if status == 401 or "401" in str(e) or "unauthenticated" in str(e).lower():
|
||||
snap.unavailable_reason = "Авторизация истекла — обновите подключение"
|
||||
else:
|
||||
snap.unavailable_reason = "Провайдер не вернул данные лимитов"
|
||||
|
||||
with self._cache_lock:
|
||||
self._snapshots[key] = snap
|
||||
|
||||
if snap.source == "provider_api":
|
||||
measured_by_family: dict[str, float] = {}
|
||||
for bucket in snap.buckets:
|
||||
family = bucket.model_family
|
||||
remaining = bucket.remaining_percent
|
||||
if not family or remaining is None:
|
||||
continue
|
||||
measured_by_family[family] = min(measured_by_family.get(family, 100.0), float(remaining))
|
||||
if measured_by_family:
|
||||
try:
|
||||
from .router_engine import get_router_engine
|
||||
|
||||
get_router_engine().health.reconcile_measured_quota(profile_id, measured_by_family)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc)
|
||||
|
||||
# Notify listeners
|
||||
for listener in list(self._listeners):
|
||||
try:
|
||||
|
|
@ -164,17 +187,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 +333,171 @@ 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")
|
||||
|
||||
refresh_token = token_data.get("refresh_token") or token_data.get("refresh")
|
||||
|
||||
def _refresh_and_save() -> str:
|
||||
if not refresh_token:
|
||||
raise RuntimeError("OAuth-сессия истекла, refresh token отсутствует")
|
||||
refreshed = refresh_access_token(str(refresh_token))
|
||||
token_data.update(refreshed)
|
||||
auth_data["token"] = token_data
|
||||
ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data)
|
||||
return str(refreshed["access_token"])
|
||||
|
||||
expiry = _parse_datetime(token_data.get("expires_at") or token_data.get("expiry"))
|
||||
if expiry and expiry <= now + timedelta(seconds=60):
|
||||
access_token = _refresh_and_save()
|
||||
|
||||
project_id = auth_data.get("project_id") or auth_data.get("projectId")
|
||||
if not project_id:
|
||||
try:
|
||||
project_id = load_or_onboard_project(str(access_token))
|
||||
except Exception as exc:
|
||||
if (getattr(exc, "status", None) != 401 and "401" not in str(exc)) or not refresh_token:
|
||||
raise
|
||||
access_token = _refresh_and_save()
|
||||
project_id = load_or_onboard_project(str(access_token))
|
||||
auth_data["project_id"] = project_id
|
||||
ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data)
|
||||
|
||||
def _fetch(token: str, operation: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
f"https://daily-cloudcode-pa.googleapis.com/v1internal:{operation}",
|
||||
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 "{}")
|
||||
|
||||
def _fetch_with_refresh(operation: str) -> dict[str, Any]:
|
||||
nonlocal access_token
|
||||
try:
|
||||
return _fetch(str(access_token), operation)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 401 or not refresh_token:
|
||||
raise
|
||||
access_token = _refresh_and_save()
|
||||
return _fetch(access_token, operation)
|
||||
|
||||
# This is the endpoint used by the Antigravity usage screen. It
|
||||
# exposes the four semantic buckets: Gemini and Claude/GPT, each with
|
||||
# a five-hour and weekly window. Treat it as best-effort so older
|
||||
# accounts can still fall back to per-model capacity below.
|
||||
try:
|
||||
summary_payload = _fetch_with_refresh("retrieveUserQuotaSummary")
|
||||
except Exception as exc:
|
||||
logger.info("Grouped Antigravity quota unavailable for %s: %s", profile_id, exc)
|
||||
summary_payload = {}
|
||||
|
||||
summary_groups = summary_payload.get("groups") if isinstance(summary_payload, dict) else None
|
||||
grouped: dict[tuple[str, str], QuotaBucket] = {}
|
||||
if isinstance(summary_groups, list):
|
||||
for group in summary_groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
group_name = str(group.get("displayName") or group.get("description") or "").lower()
|
||||
family = "gemini" if "gemini" in group_name else "claude" if "claude" in group_name else None
|
||||
family_label = "Gemini" if family == "gemini" else "Claude/GPT"
|
||||
if family is None:
|
||||
continue
|
||||
for bucket_data in group.get("buckets") or []:
|
||||
if not isinstance(bucket_data, dict):
|
||||
continue
|
||||
remaining_fraction = bucket_data.get("remainingFraction")
|
||||
window_raw = str(bucket_data.get("window") or "").lower()
|
||||
window = "7d" if "week" in window_raw or window_raw == "7d" else "5h" if "5" in window_raw else ""
|
||||
if not isinstance(remaining_fraction, (int, float)) or not window:
|
||||
continue
|
||||
window_label = "неделя" if window == "7d" else "5 часов"
|
||||
grouped[(family, window)] = QuotaBucket(
|
||||
id=f"antigravity.{family}.{window}",
|
||||
display_name=f"{family_label} • {window_label}",
|
||||
model_family=family,
|
||||
remaining_percent=max(0.0, min(100.0, float(remaining_fraction) * 100.0)),
|
||||
reset_at=_parse_datetime(bucket_data.get("resetTime")),
|
||||
period=window,
|
||||
unit="model capacity",
|
||||
scope="model_family",
|
||||
)
|
||||
ordered_keys = (("claude", "5h"), ("gemini", "5h"), ("claude", "7d"), ("gemini", "7d"))
|
||||
if all(key in grouped for key in ordered_keys):
|
||||
return QuotaSnapshot(
|
||||
account_id=profile_id,
|
||||
provider="antigravity",
|
||||
buckets=[grouped[key] for key in ordered_keys],
|
||||
fetched_at=now,
|
||||
source="provider_api",
|
||||
)
|
||||
|
||||
payload = _fetch_with_refresh("fetchAvailableModels")
|
||||
|
||||
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:
|
||||
|
|
@ -402,44 +533,85 @@ class AccountQuotaService:
|
|||
)
|
||||
|
||||
def _collect_opencode_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
"""Collect Sliding, Weekly, and Monthly usage for OpenCode Go."""
|
||||
"""Validate OpenCode Go entitlement and read usage when its API exposes it."""
|
||||
now = _utc_now()
|
||||
b_sliding = QuotaBucket(
|
||||
id="opencode.sliding",
|
||||
display_name="Скользящее",
|
||||
model_family="opencode",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="sliding",
|
||||
status="unknown",
|
||||
)
|
||||
b_weekly = QuotaBucket(
|
||||
id="opencode.weekly",
|
||||
display_name="Недельное",
|
||||
model_family="opencode",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=now + timedelta(days=7),
|
||||
status="unknown",
|
||||
)
|
||||
b_monthly = QuotaBucket(
|
||||
id="opencode.monthly",
|
||||
display_name="Ежемесячное",
|
||||
model_family="opencode",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="30d",
|
||||
reset_at=now + timedelta(days=30),
|
||||
status="unknown",
|
||||
api_key = auth_data.get("api_key")
|
||||
if not api_key:
|
||||
raise RuntimeError("Ключ OpenCode Go не сохранён")
|
||||
|
||||
base_url = "https://opencode.ai/zen/go/v1"
|
||||
|
||||
def _get(path: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
f"{base_url}{path}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-hub/1.0",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8") or "{}")
|
||||
|
||||
# /models is the documented read-only endpoint and confirms that the
|
||||
# key is accepted without spending a request from the user's limit.
|
||||
_get("/models")
|
||||
usage: dict[str, Any] = {}
|
||||
unavailable_reason: Optional[str] = None
|
||||
try:
|
||||
usage = _get("/usage")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
if exc.code == 403 and "subscription required" in raw.lower():
|
||||
unavailable_reason = "Для этого ключа не активна подписка OpenCode Go"
|
||||
elif exc.code in (403, 404):
|
||||
unavailable_reason = "OpenCode Go не предоставляет остаток через публичный API"
|
||||
else:
|
||||
raise
|
||||
|
||||
def _metric(*names: str) -> dict[str, Any]:
|
||||
for name in names:
|
||||
value = usage.get(name)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
buckets: list[QuotaBucket] = []
|
||||
specs = (
|
||||
("5h", "Лимит 5 часов", 12, _metric("five_hour", "fiveHour", "sliding")),
|
||||
("7d", "Недельный лимит", 30, _metric("weekly", "seven_day", "sevenDay")),
|
||||
("30d", "Месячный лимит", 60, _metric("monthly", "thirty_day", "thirtyDay")),
|
||||
)
|
||||
for period, label, limit_value, metric in specs:
|
||||
remaining_percent = metric.get("remaining_percent", metric.get("remainingPercentage"))
|
||||
remaining_absolute = metric.get("remaining", metric.get("remaining_amount"))
|
||||
used_absolute = metric.get("used", metric.get("used_amount"))
|
||||
reset_at = _parse_datetime(metric.get("reset_at") or metric.get("resetTime"))
|
||||
buckets.append(
|
||||
QuotaBucket(
|
||||
id=f"opencode.{period}",
|
||||
display_name=label,
|
||||
model_family="opencode",
|
||||
remaining_percent=float(remaining_percent) if isinstance(remaining_percent, (int, float)) else None,
|
||||
used_absolute=int(used_absolute) if isinstance(used_absolute, (int, float)) else None,
|
||||
remaining_absolute=(
|
||||
int(remaining_absolute) if isinstance(remaining_absolute, (int, float)) else None
|
||||
),
|
||||
limit_absolute=limit_value,
|
||||
reset_at=reset_at,
|
||||
period=period,
|
||||
unit="USD",
|
||||
scope="account",
|
||||
)
|
||||
)
|
||||
|
||||
return QuotaSnapshot(
|
||||
account_id=profile_id,
|
||||
provider="opencode-go",
|
||||
buckets=[b_sliding, b_weekly, b_monthly],
|
||||
buckets=buckets,
|
||||
fetched_at=now,
|
||||
source="baseline",
|
||||
source="provider_api",
|
||||
unavailable_reason=unavailable_reason,
|
||||
)
|
||||
|
||||
def _collect_claude_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
|
|
|
|||
|
|
@ -24,9 +24,23 @@ from antigravity_provider.router.ui.theme import Theme
|
|||
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
from antigravity_provider.router.unified_health import EventLogService
|
||||
|
||||
|
||||
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
|
||||
"""Keep existing chain rank or route a newly introduced profile slot."""
|
||||
config = load_router_config()
|
||||
assigned_role = next(
|
||||
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
|
||||
"",
|
||||
)
|
||||
if assigned_role:
|
||||
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
|
||||
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
|
||||
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
|
||||
|
||||
|
||||
class AddAccountWizard(HubModal):
|
||||
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""
|
||||
|
||||
|
|
@ -198,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()
|
||||
|
|
@ -222,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
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -470,10 +515,10 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
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))
|
||||
|
|
@ -490,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")
|
||||
|
|
@ -502,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))
|
||||
|
|
@ -510,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))
|
||||
|
|
@ -535,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))
|
||||
|
|
@ -641,6 +693,14 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
def _open_codex_browser(self):
|
||||
if self.codex_url:
|
||||
self._copy_codex_code()
|
||||
self.codex_status_lbl.configure(
|
||||
text=(
|
||||
"Код скопирован. Вставьте его на открывшейся странице OpenAI; "
|
||||
"затем вернитесь сюда — Hub продолжит автоматически."
|
||||
),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
webbrowser.open(self.codex_url)
|
||||
|
||||
def _handle_codex_manual_submit(self):
|
||||
|
|
@ -745,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")
|
||||
|
|
@ -906,10 +966,10 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
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))
|
||||
|
|
@ -938,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))
|
||||
|
|
@ -946,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))
|
||||
|
|
@ -971,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))
|
||||
|
|
@ -1077,6 +1144,14 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
def _open_grok_browser(self):
|
||||
if self.grok_url:
|
||||
self._copy_grok_code()
|
||||
self.grok_status_lbl.configure(
|
||||
text=(
|
||||
"Код скопирован. Вставьте его на открывшейся странице xAI; "
|
||||
"затем вернитесь сюда — Hub продолжит автоматически."
|
||||
),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
webbrowser.open(self.grok_url)
|
||||
|
||||
def _handle_grok_manual_submit(self):
|
||||
|
|
@ -1223,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,
|
||||
|
|
@ -1305,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
|
||||
|
|
@ -1441,6 +1524,15 @@ class AddAccountWizard(HubModal):
|
|||
anchor="w",
|
||||
).pack(fill="x", pady=8)
|
||||
|
||||
self.finish_status_lbl = ctk.CTkLabel(
|
||||
self.body,
|
||||
text="Нажмите «Завершить подключение», чтобы сохранить роль и обновить Hub.",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
anchor="w",
|
||||
)
|
||||
self.finish_status_lbl.pack(fill="x", pady=(0, 4))
|
||||
|
||||
HubButton(
|
||||
self.footer,
|
||||
text="✓ Завершить подключение",
|
||||
|
|
@ -1449,13 +1541,40 @@ 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.
|
||||
ok, message = ensure_profile_in_routing(self.target_slot)
|
||||
if not ok:
|
||||
self.finish_status_lbl.configure(text=f"❌ {message}", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
|
||||
# Всё, что проверяемо, уже проверено выше и сообщает об ошибке в самом
|
||||
# окне. Дальше идут побочные действия, и ни одно из них не должно
|
||||
# мешать закрытию: исключение здесь оставляло мастер открытым без
|
||||
# единого признака ошибки, потому что под pythonw трейсбека не видно.
|
||||
try:
|
||||
# A reused slot can carry cooldown from an older account. Fresh OAuth
|
||||
# credentials must start with fresh health state.
|
||||
from antigravity_provider.router.router_engine import get_router_engine
|
||||
|
||||
get_router_engine().health.clear_cooldown(self.target_slot)
|
||||
EventLogService.get().log(
|
||||
"account",
|
||||
f"Подключен аккаунт {self.selected_provider}: {self.discovered_identity}",
|
||||
details=f"Слот: {self.target_slot}",
|
||||
f"Подключен аккаунт {self.selected_provider}; слот {self.target_slot}; роль сохранена.",
|
||||
level="success",
|
||||
)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -481,7 +481,8 @@ class HubModal(ctk.CTkToplevel):
|
|||
self,
|
||||
corner_radius=Theme.RADIUS_LG,
|
||||
border_color=Theme.BORDER_ACCENT,
|
||||
fg_color=Theme.DARK,
|
||||
# Contrast-safe in dark, hybrid and light palettes.
|
||||
fg_color=Theme.SURFACE,
|
||||
)
|
||||
self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG)
|
||||
|
||||
|
|
@ -867,7 +868,7 @@ class AccountCardWidget(HubCard):
|
|||
self.profile_id = profile_id
|
||||
self.profile_model: Any = None
|
||||
self.on_action = on_action
|
||||
self.compact = compact
|
||||
self.compact = True
|
||||
self._quota_widgets: Dict[str, QuotaBucketWidget] = {}
|
||||
self.widgets_created = 0
|
||||
self.widgets_destroyed = 0
|
||||
|
|
@ -876,8 +877,8 @@ class AccountCardWidget(HubCard):
|
|||
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
|
||||
self.provider = ctk.CTkLabel(top, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.provider.pack(side="left")
|
||||
self.toggle = IconButton(top, text="▾", command=self.toggle_compact)
|
||||
self.toggle.pack(side="right")
|
||||
self.profile_mark = ctk.CTkLabel(top, text="◇", font=Theme.font_caption(), text_color=Theme.TEXT_ACCENT)
|
||||
self.profile_mark.pack(side="right")
|
||||
|
||||
self.identity = EllipsizedLabel(self, text=identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
|
||||
|
|
@ -887,6 +888,47 @@ 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_cells: list[dict[str, Any]] = []
|
||||
for index in range(4):
|
||||
cell = ctk.CTkFrame(self.compact_quota, fg_color="transparent")
|
||||
cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 10), pady=(2, 6))
|
||||
title = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY, anchor="w")
|
||||
title.pack(fill="x")
|
||||
value = ctk.CTkLabel(cell, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, anchor="e")
|
||||
value.pack(fill="x")
|
||||
progress = ctk.CTkProgressBar(
|
||||
cell,
|
||||
height=5,
|
||||
corner_radius=3,
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
progress_color=Theme.STATUS_HEALTHY,
|
||||
)
|
||||
progress.pack(fill="x", pady=(2, 1))
|
||||
progress.set(0)
|
||||
reset = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, anchor="w")
|
||||
reset.pack(fill="x")
|
||||
self.compact_quota_cells.append(
|
||||
{"frame": cell, "title": title, "value": value, "progress": progress, "reset": reset}
|
||||
)
|
||||
self.compact_quota.grid_columnconfigure(index % 2, weight=1)
|
||||
self.compact_actions = ctk.CTkFrame(self, fg_color="transparent")
|
||||
for text, action in (
|
||||
("⚡", "test"),
|
||||
("★", "set_main"),
|
||||
("♛", "set_orchestrator"),
|
||||
("Роль", "assign_role"),
|
||||
("↻", "refresh_account"),
|
||||
):
|
||||
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")
|
||||
|
|
@ -896,6 +938,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))
|
||||
|
|
@ -926,7 +977,7 @@ class AccountCardWidget(HubCard):
|
|||
width=72,
|
||||
command=lambda: self._trigger("delete_credentials"),
|
||||
).pack(side="right")
|
||||
self.set_compact(compact)
|
||||
self.set_compact(True)
|
||||
|
||||
@staticmethod
|
||||
def resolve_identity(profile: Any) -> str:
|
||||
|
|
@ -943,18 +994,28 @@ 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)
|
||||
# Account cards intentionally have one fixed Cockpit-style layout.
|
||||
self.set_compact(True)
|
||||
|
||||
def set_compact(self, compact: bool) -> None:
|
||||
self.compact = compact
|
||||
self.toggle.configure(text="▸" if compact else "▾")
|
||||
if compact:
|
||||
self.details.pack_forget()
|
||||
else:
|
||||
self.details.pack(fill="x")
|
||||
self.compact = True
|
||||
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))
|
||||
|
||||
def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None:
|
||||
self.profile_model = profile
|
||||
|
|
@ -974,12 +1035,53 @@ class AccountCardWidget(HubCard):
|
|||
)
|
||||
else:
|
||||
self.plan_badge.pack_forget()
|
||||
self.status.set_status(profile.health_state, getattr(profile, "health_label_ru", None))
|
||||
self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER)
|
||||
|
||||
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
|
||||
unavailable_reason = getattr(snapshot, "unavailable_reason", None) if snapshot else None
|
||||
measured_remaining = [
|
||||
float(bucket.remaining_percent)
|
||||
for bucket in buckets
|
||||
if getattr(bucket, "remaining_percent", None) is not None
|
||||
]
|
||||
if measured_remaining and not estimated:
|
||||
if all(remaining <= 0 for remaining in measured_remaining):
|
||||
card_health, card_label = "quota_exhausted", "Квота исчерпана"
|
||||
elif any(remaining <= 0 for remaining in measured_remaining):
|
||||
card_health, card_label = "warning", "Часть квот исчерпана"
|
||||
else:
|
||||
card_health, card_label = "healthy", "Работает"
|
||||
else:
|
||||
card_health = profile.health_state
|
||||
card_label = getattr(profile, "health_label_ru", None)
|
||||
self.status.set_status(card_health, card_label)
|
||||
for index, cell in enumerate(self.compact_quota_cells):
|
||||
if index < len(buckets):
|
||||
bucket = buckets[index]
|
||||
remaining = getattr(bucket, "remaining_percent", None)
|
||||
color = (
|
||||
Theme.STATUS_HEALTHY
|
||||
if getattr(bucket, "status", "unknown") == "healthy"
|
||||
else Theme.STATUS_WARNING
|
||||
if getattr(bucket, "status", "unknown") == "warning"
|
||||
else Theme.TEXT_MUTED
|
||||
)
|
||||
detail = bucket.formatted_remaining()
|
||||
if detail == "Н/Д" and unavailable_reason:
|
||||
limit = getattr(bucket, "limit_absolute", None)
|
||||
unit = getattr(bucket, "unit", None)
|
||||
prefix = f"Лимит ${limit} • " if limit is not None and unit == "USD" else ""
|
||||
detail = f"{prefix}{unavailable_reason}"
|
||||
cell["title"].configure(text=bucket.display_name)
|
||||
cell["value"].configure(text=detail, text_color=color)
|
||||
cell["progress"].configure(progress_color=color)
|
||||
cell["progress"].set(float(remaining) / 100.0 if remaining is not None else 0)
|
||||
cell["reset"].configure(text=bucket.formatted_reset() or "Период указан провайдером")
|
||||
cell["frame"].grid()
|
||||
else:
|
||||
cell["frame"].grid_remove()
|
||||
seen: set[str] = set()
|
||||
for bucket in buckets:
|
||||
key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket"))
|
||||
|
|
|
|||
273
src/antigravity_provider/router/ui/routing_graph.py
Normal file
273
src/antigravity_provider/router/ui/routing_graph.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Versioned presentation model for the Team routing graph.
|
||||
|
||||
The router YAML remains the source of truth for profiles and role chains. This
|
||||
module stores only topology/layout metadata next to it and applies profile
|
||||
assignments through :class:`AutoAssigner`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.router_config import RouterConfig, load_router_config
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EDGE_TYPES = ("PRIMARY", "FALLBACK", "DELEGATE")
|
||||
CANONICAL_ROLES = ("orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast")
|
||||
ROLE_LABELS = {
|
||||
"orchestrator": "Оркестратор",
|
||||
"coder-primary": "Основной кодер",
|
||||
"coder-secondary": "Резервный кодер",
|
||||
"reviewer": "Ревьюер",
|
||||
"research": "Исследователь",
|
||||
"fast": "Быстрый агент",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphNode:
|
||||
role_id: str
|
||||
x: float
|
||||
y: float
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphEdge:
|
||||
source: str
|
||||
target: str
|
||||
edge_type: str = "DELEGATE"
|
||||
profile_id: str = ""
|
||||
edge_id: str = field(default_factory=lambda: uuid4().hex[:12])
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphIssue:
|
||||
code: str
|
||||
message: str
|
||||
node_id: str = ""
|
||||
edge_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingGraph:
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
nodes: list[GraphNode] = field(default_factory=list)
|
||||
edges: list[GraphEdge] = field(default_factory=list)
|
||||
zoom: float = 1.0
|
||||
viewport_x: float = 0.0
|
||||
viewport_y: float = 0.0
|
||||
|
||||
def clone(self) -> "RoutingGraph":
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict[str, Any]) -> "RoutingGraph":
|
||||
if int(value.get("schema_version", 0)) != SCHEMA_VERSION:
|
||||
raise ValueError("Unsupported routing graph schema")
|
||||
return cls(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
nodes=[GraphNode(**item) for item in value.get("nodes", [])],
|
||||
edges=[GraphEdge(**item) for item in value.get("edges", [])],
|
||||
zoom=float(value.get("zoom", 1.0)),
|
||||
viewport_x=float(value.get("viewport_x", 0.0)),
|
||||
viewport_y=float(value.get("viewport_y", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def default_graph(config: Optional[RouterConfig] = None) -> RoutingGraph:
|
||||
"""Migrate current configured roles without touching their chains."""
|
||||
config = config or load_router_config()
|
||||
roles = [role for role in CANONICAL_ROLES if role in config.roles]
|
||||
roles.extend(role for role in config.roles if role not in roles)
|
||||
nodes: list[GraphNode] = []
|
||||
for index, role_id in enumerate(roles):
|
||||
if role_id == "orchestrator":
|
||||
x, y = 90.0, 240.0
|
||||
else:
|
||||
slot = index - (1 if "orchestrator" in roles else 0)
|
||||
x, y = 390.0 + (slot // 3) * 300.0, 80.0 + (slot % 3) * 160.0
|
||||
nodes.append(GraphNode(role_id, x, y, ROLE_LABELS.get(role_id, role_id)))
|
||||
root = "orchestrator" if "orchestrator" in roles else (roles[0] if roles else "")
|
||||
edges = [GraphEdge(root, role, "DELEGATE") for role in roles if root and role != root]
|
||||
return RoutingGraph(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
class RoutingGraphStore:
|
||||
def __init__(self, path: Optional[Path] = None):
|
||||
self.path = path or (paths.get_hermes_home() / "routing_graph.json")
|
||||
|
||||
def load(self, config: Optional[RouterConfig] = None) -> RoutingGraph:
|
||||
try:
|
||||
return RoutingGraph.from_dict(json.loads(self.path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return default_graph(config)
|
||||
|
||||
def save(self, graph: RoutingGraph) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(graph.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
|
||||
def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -> list[GraphIssue]:
|
||||
config = config or load_router_config()
|
||||
issues: list[GraphIssue] = []
|
||||
node_ids = [node.role_id for node in graph.nodes]
|
||||
node_set = set(node_ids)
|
||||
for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}):
|
||||
issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id))
|
||||
if "orchestrator" not in node_set:
|
||||
issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора"))
|
||||
for node in graph.nodes:
|
||||
policy = config.roles.get(node.role_id)
|
||||
if policy is None:
|
||||
issues.append(GraphIssue("missing-role", f"Роль {node.role_id} отсутствует в конфигурации", node.role_id))
|
||||
elif not policy.preferred_chain:
|
||||
issues.append(GraphIssue("empty-chain", f"У роли {node.label or node.role_id} нет профилей", node.role_id))
|
||||
else:
|
||||
for profile_id in policy.preferred_chain:
|
||||
if profile_id not in config.profiles:
|
||||
issues.append(
|
||||
GraphIssue("missing-profile", f"Профиль {profile_id} не существует", node.role_id)
|
||||
)
|
||||
seen_edges: set[tuple[str, str, str, str]] = set()
|
||||
adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_set}
|
||||
for edge in graph.edges:
|
||||
key = (edge.source, edge.target, edge.edge_type, edge.profile_id)
|
||||
if key in seen_edges:
|
||||
issues.append(GraphIssue("duplicate-edge", "Дублирующая связь", edge_id=edge.edge_id))
|
||||
seen_edges.add(key)
|
||||
if edge.edge_type not in EDGE_TYPES:
|
||||
issues.append(GraphIssue("edge-type", f"Неизвестный тип {edge.edge_type}", edge_id=edge.edge_id))
|
||||
if edge.source not in node_set or edge.target not in node_set:
|
||||
issues.append(GraphIssue("dangling-edge", "Связь ведёт к отсутствующей роли", edge_id=edge.edge_id))
|
||||
continue
|
||||
adjacency[edge.source].append(edge.target)
|
||||
if edge.profile_id and edge.profile_id not in config.profiles:
|
||||
issues.append(GraphIssue("missing-profile", f"Профиль {edge.profile_id} не существует", edge.target, edge.edge_id))
|
||||
|
||||
visited: set[str] = set()
|
||||
active: set[str] = set()
|
||||
|
||||
def visit(role_id: str) -> None:
|
||||
if role_id in active:
|
||||
issues.append(GraphIssue("cycle", f"Цикл маршрутизации через {role_id}", role_id))
|
||||
return
|
||||
if role_id in visited:
|
||||
return
|
||||
visited.add(role_id)
|
||||
active.add(role_id)
|
||||
for target in adjacency.get(role_id, []):
|
||||
visit(target)
|
||||
active.remove(role_id)
|
||||
|
||||
if "orchestrator" in node_set:
|
||||
visit("orchestrator")
|
||||
for role_id in sorted(node_set - visited):
|
||||
issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id))
|
||||
return issues
|
||||
|
||||
|
||||
class RoutingGraphController:
|
||||
"""Undoable graph editor whose assignments go through AutoAssigner."""
|
||||
|
||||
def __init__(self, store: Optional[RoutingGraphStore] = None):
|
||||
self.store = store or RoutingGraphStore()
|
||||
self.graph = self.store.load()
|
||||
self._undo: list[RoutingGraph] = []
|
||||
self._redo: list[RoutingGraph] = []
|
||||
self.dirty = False
|
||||
|
||||
def _checkpoint(self) -> None:
|
||||
self._undo.append(self.graph.clone())
|
||||
self._undo = self._undo[-50:]
|
||||
self._redo.clear()
|
||||
self.dirty = True
|
||||
|
||||
def move_node(self, role_id: str, x: float, y: float) -> None:
|
||||
node = next((item for item in self.graph.nodes if item.role_id == role_id), None)
|
||||
if node and (node.x, node.y) != (x, y):
|
||||
self._checkpoint()
|
||||
node.x, node.y = x, y
|
||||
|
||||
def add_edge(self, source: str, target: str, edge_type: str, profile_id: str = "") -> tuple[bool, str]:
|
||||
if edge_type not in EDGE_TYPES:
|
||||
return False, "Неизвестный тип связи"
|
||||
self._checkpoint()
|
||||
self.graph.edges.append(GraphEdge(source, target, edge_type, profile_id))
|
||||
if profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
|
||||
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target, edge_type == "PRIMARY")
|
||||
if not ok:
|
||||
self.undo()
|
||||
return False, message
|
||||
return True, "Связь добавлена"
|
||||
|
||||
def delete_edge(self, edge_id: str) -> None:
|
||||
if any(edge.edge_id == edge_id for edge in self.graph.edges):
|
||||
self._checkpoint()
|
||||
self.graph.edges = [edge for edge in self.graph.edges if edge.edge_id != edge_id]
|
||||
|
||||
def set_edge_type(
|
||||
self, edge_id: str, edge_type: str, profile_id: Optional[str] = None
|
||||
) -> tuple[bool, str]:
|
||||
edge = next((item for item in self.graph.edges if item.edge_id == edge_id), None)
|
||||
if edge is None or edge_type not in EDGE_TYPES:
|
||||
return False, "Связь не найдена"
|
||||
self._checkpoint()
|
||||
edge.edge_type = edge_type
|
||||
if profile_id is not None:
|
||||
edge.profile_id = profile_id
|
||||
if edge.profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
|
||||
ok, message = AutoAssigner.assign_profile_to_role(edge.profile_id, edge.target, edge_type == "PRIMARY")
|
||||
if not ok:
|
||||
self.undo()
|
||||
return ok, message
|
||||
return True, "Тип связи изменён"
|
||||
|
||||
def auto_layout(self) -> None:
|
||||
self._checkpoint()
|
||||
root = next((node for node in self.graph.nodes if node.role_id == "orchestrator"), None)
|
||||
if root:
|
||||
root.x, root.y = 80.0, 240.0
|
||||
others = [node for node in self.graph.nodes if node.role_id != "orchestrator"]
|
||||
for index, node in enumerate(others):
|
||||
node.x = 390.0 + (index // 4) * 290.0
|
||||
node.y = 55.0 + (index % 4) * 135.0
|
||||
|
||||
def undo(self) -> bool:
|
||||
if not self._undo:
|
||||
return False
|
||||
self._redo.append(self.graph.clone())
|
||||
self.graph = self._undo.pop()
|
||||
self.dirty = True
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
if not self._redo:
|
||||
return False
|
||||
self._undo.append(self.graph.clone())
|
||||
self.graph = self._redo.pop()
|
||||
self.dirty = True
|
||||
return True
|
||||
|
||||
def save(self) -> list[GraphIssue]:
|
||||
issues = validate_graph(self.graph)
|
||||
if not issues:
|
||||
self.store.save(self.graph)
|
||||
self.dirty = False
|
||||
return issues
|
||||
|
||||
def role_chain(self, role_id: str) -> Iterable[str]:
|
||||
policy = load_router_config().roles.get(role_id)
|
||||
return tuple(policy.preferred_chain if policy else ())
|
||||
|
|
@ -29,7 +29,7 @@ PROVIDER_LABELS = {
|
|||
|
||||
|
||||
class ProviderGroup(ctk.CTkFrame):
|
||||
"""Collapsible provider section that retains its account cards while hidden."""
|
||||
"""Fixed provider section with always-visible Cockpit-style cards."""
|
||||
|
||||
def __init__(self, master: Any, provider: str):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
|
|
@ -37,21 +37,13 @@ class ProviderGroup(ctk.CTkFrame):
|
|||
self.collapsed = False
|
||||
self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM)
|
||||
self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS))
|
||||
self.toggle = ActionButton(
|
||||
self.header,
|
||||
text="▾",
|
||||
variant="ghost",
|
||||
width=34,
|
||||
command=self.toggle_collapsed,
|
||||
)
|
||||
self.toggle.pack(side="left", padx=(Theme.SPACE_SM, 0))
|
||||
self.title = ctk.CTkLabel(
|
||||
self.header,
|
||||
text=PROVIDER_LABELS.get(provider, provider),
|
||||
font=Theme.font_heading(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
self.title.pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||||
self.title.pack(side="left", padx=Theme.SPACE_MD, pady=Theme.SPACE_SM)
|
||||
self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||||
self.count.pack(side="right", padx=Theme.SPACE_MD)
|
||||
self.body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
|
|
@ -60,12 +52,8 @@ class ProviderGroup(ctk.CTkFrame):
|
|||
self.body.grid_columnconfigure(column, weight=1)
|
||||
|
||||
def toggle_collapsed(self) -> None:
|
||||
self.collapsed = not self.collapsed
|
||||
self.toggle.configure(text="▸" if self.collapsed else "▾")
|
||||
if self.collapsed:
|
||||
self.body.pack_forget()
|
||||
else:
|
||||
self.body.pack(fill="x")
|
||||
self.collapsed = False
|
||||
self.body.pack(fill="x")
|
||||
|
||||
|
||||
class AccountsView(ctk.CTkFrame):
|
||||
|
|
@ -91,7 +79,7 @@ class AccountsView(ctk.CTkFrame):
|
|||
header = SectionHeader(
|
||||
self,
|
||||
title="Аккаунты и квоты",
|
||||
subtitle="Реальные идентичности, независимые лимитные корзины и резервные роли",
|
||||
subtitle="Компактные карточки аккаунтов, реальные остатки и быстрые действия",
|
||||
action_text="+ Добавить аккаунт",
|
||||
action_cmd=lambda: self._emit("add_account", {}),
|
||||
)
|
||||
|
|
@ -220,6 +208,7 @@ class AccountsView(ctk.CTkFrame):
|
|||
profile.profile_id,
|
||||
AccountCardWidget.resolve_identity(profile),
|
||||
profile.provider_display_name,
|
||||
compact=True,
|
||||
on_action=self._emit,
|
||||
)
|
||||
self._cards[profile.profile_id] = card
|
||||
|
|
@ -267,3 +256,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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import customtkinter as ctk
|
|||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
from antigravity_provider.router.ui.components import HubCard
|
||||
from antigravity_provider.router.ui.components import HubButton, HubCard
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
|
|
@ -83,16 +83,31 @@ class _EndpointCard(HubCard):
|
|||
self.subtitle.pack(anchor="w", pady=(0, 1))
|
||||
self.status = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY)
|
||||
self.status.pack(anchor="w")
|
||||
quota = ctk.CTkFrame(self, fg_color="transparent", width=58)
|
||||
quota = ctk.CTkFrame(self, fg_color="transparent", width=92)
|
||||
quota.pack(side="right", fill="y", padx=(3, 8), pady=8)
|
||||
quota.pack_propagate(False)
|
||||
self.quota_label = ctk.CTkLabel(quota, text="Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.quota_label = ctk.CTkLabel(quota, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.quota_label.pack(anchor="e")
|
||||
self.progress = ctk.CTkProgressBar(
|
||||
quota, height=4, corner_radius=2, progress_color=Theme.STATUS_HEALTHY, fg_color=Theme.SURFACE_MUTED
|
||||
)
|
||||
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)
|
||||
|
|
@ -180,16 +195,19 @@ class _RouteDiagram(ctk.CTkFrame):
|
|||
self.context, text="▤ Хранилище контекста", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(pady=(6, 0))
|
||||
self.context_status = ctk.CTkLabel(
|
||||
self.context, text="● Состояние: Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||
self.context,
|
||||
text="● Нет телеметрии хранилища",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
)
|
||||
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 +215,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 +241,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,
|
||||
|
|
@ -373,13 +393,35 @@ class DashboardView(ctk.CTkFrame):
|
|||
# Kept as a presentation-state probe for tests/accessibility; the same
|
||||
# status is rendered once in the global header, as in the approved mockup.
|
||||
|
||||
self.empty_state = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.ACCENT_DIM)
|
||||
empty_copy = ctk.CTkFrame(self.empty_state, fg_color="transparent")
|
||||
empty_copy.pack(side="left", fill="x", expand=True, padx=14, pady=10)
|
||||
ctk.CTkLabel(
|
||||
empty_copy,
|
||||
text="Подключите первый аккаунт",
|
||||
font=Theme.font_heading(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w")
|
||||
ctk.CTkLabel(
|
||||
empty_copy,
|
||||
text="Hermes назначит профиль роли и покажет реальную квоту, модель и цепочку отказоустойчивости.",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(anchor="w", pady=(2, 0))
|
||||
HubButton(
|
||||
self.empty_state,
|
||||
text="Добавить аккаунт",
|
||||
variant="primary",
|
||||
command=lambda: self.on_action("add_account", {}) if self.on_action else None,
|
||||
).pack(side="right", padx=12, pady=10)
|
||||
|
||||
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
self.metrics.pack(fill="x", pady=(0, 8))
|
||||
for column in range(5):
|
||||
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(
|
||||
|
|
@ -389,10 +431,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
|
||||
|
|
@ -400,8 +441,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,
|
||||
|
|
@ -473,7 +516,17 @@ class DashboardView(ctk.CTkFrame):
|
|||
for bucket in quota.buckets:
|
||||
if bucket.remaining_percent is not None:
|
||||
return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent)
|
||||
return "Н/Д", None
|
||||
return "Нет данных API", None
|
||||
|
||||
@staticmethod
|
||||
def _agent_quota_measurement(snapshot: HubSnapshot, agent: Any) -> tuple[str, Optional[float]]:
|
||||
quota = snapshot.quotas.get(agent.assigned_profile_id)
|
||||
if quota and not getattr(quota, "is_estimated", True):
|
||||
bucket = quota.get_bucket_for_model(agent.model)
|
||||
if bucket and bucket.remaining_percent is not None:
|
||||
remaining = float(bucket.remaining_percent)
|
||||
return bucket.formatted_remaining(), remaining
|
||||
return agent.active_quota_label or "Нет данных API", None
|
||||
|
||||
@staticmethod
|
||||
def _sync_endpoint_cards(
|
||||
|
|
@ -481,7 +534,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:
|
||||
|
|
@ -507,6 +560,10 @@ class DashboardView(ctk.CTkFrame):
|
|||
text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
|
||||
)
|
||||
readiness = snapshot.readiness
|
||||
if readiness.accounts_connected_count == 0:
|
||||
self.empty_state.pack(fill="x", pady=(0, 8), before=self.metrics)
|
||||
else:
|
||||
self.empty_state.pack_forget()
|
||||
telemetry = dict(snapshot.metrics.get("telemetry") or {})
|
||||
global_telemetry = dict(telemetry.get("global") or {})
|
||||
provider_telemetry = dict(telemetry.get("by_provider") or {})
|
||||
|
|
@ -526,9 +583,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 "Н/Д",
|
||||
|
|
@ -550,13 +607,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),
|
||||
|
|
@ -575,33 +635,51 @@ 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]
|
||||
self._sync_endpoint_cards(
|
||||
self.route_diagram.agent_slots,
|
||||
self._agent_cards,
|
||||
(
|
||||
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5]
|
||||
agent_items = []
|
||||
for agent in agents:
|
||||
agent_quota_text, agent_quota_percent = self._agent_quota_measurement(snapshot, agent)
|
||||
agent_items.append(
|
||||
(
|
||||
agent.role_id,
|
||||
agent.provider,
|
||||
agent.role_name_ru,
|
||||
f"{agent.provider_display_name} • {agent.model}",
|
||||
"Здорово" if agent.is_active else agent.status_label_ru,
|
||||
agent.active_quota_label or "Н/Д",
|
||||
None,
|
||||
agent_quota_text,
|
||||
agent_quota_percent,
|
||||
"healthy" if agent.is_active else "warning",
|
||||
)
|
||||
for agent in agents
|
||||
),
|
||||
)
|
||||
self._sync_endpoint_cards(
|
||||
self.route_diagram.agent_slots,
|
||||
self._agent_cards,
|
||||
agent_items,
|
||||
)
|
||||
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")
|
||||
left_labels.append(f"{share:.0%}" if share is not None else "Н/Д")
|
||||
left_labels.append(f"{share:.0%}" if share is not None else "")
|
||||
right_labels: list[str] = []
|
||||
for agent in agents:
|
||||
measured = dict(role_telemetry.get(agent.role_id) or {})
|
||||
calls = measured.get("total_calls") if measured.get("has_data") else None
|
||||
right_labels.append(f"{calls} выз." if calls is not None else "Н/Д")
|
||||
right_labels.append(f"{calls} выз." if calls is not None else "")
|
||||
self.route_diagram.set_labels(left_labels, right_labels)
|
||||
|
||||
live_provider_ids = {provider.provider_id for provider in providers}
|
||||
|
|
@ -615,11 +693,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 {})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
import tkinter as tk
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
|
|
@ -26,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}),
|
||||
|
|
@ -90,6 +91,19 @@ class RoutingView(ctk.CTkFrame):
|
|||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
|
||||
def focus_role(self, role_id: str) -> None:
|
||||
"""Focus the existing route editor/card selected in the graph inspector."""
|
||||
widget = self._role_widgets.get(role_id)
|
||||
if not widget:
|
||||
return
|
||||
for current in self._role_widgets.values():
|
||||
current.configure(border_color=Theme.BORDER)
|
||||
widget.configure(border_color=Theme.BORDER_ACCENT)
|
||||
try:
|
||||
self.scroll._parent_canvas.yview_moveto(max(0.0, widget.winfo_y() / max(1, self.scroll.winfo_height())))
|
||||
except (AttributeError, tk.TclError):
|
||||
pass
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
|
@ -24,6 +25,49 @@ 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,
|
||||
EVENT_ACCOUNT_REMOVED,
|
||||
EVENT_ACCOUNT_UPDATED,
|
||||
EVENT_QUOTA_UPDATED,
|
||||
EVENT_ROUTING_UPDATED,
|
||||
EventBus,
|
||||
)
|
||||
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):
|
||||
|
|
@ -116,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
|
||||
|
|
@ -247,153 +312,638 @@ class AgentCardWidget(HubCard):
|
|||
|
||||
|
||||
class TeamView(ctk.CTkFrame):
|
||||
"""Interactive canvas over the existing router role/profile chains."""
|
||||
|
||||
NODE_W = 218
|
||||
NODE_H = 104
|
||||
|
||||
def __init__(
|
||||
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.app_state = app_state or {}
|
||||
self.on_action = on_action
|
||||
self._card_widgets: Dict[str, AgentCardWidget] = {}
|
||||
self.controller = RoutingGraphController()
|
||||
self.snapshot: Optional[HubSnapshot] = None
|
||||
self._live_pipelines: Dict[str, Any] = {}
|
||||
self._quota_overrides: Dict[str, Any] = {}
|
||||
self.selected_role = "orchestrator"
|
||||
self.selected_edge = ""
|
||||
self._drag_role = ""
|
||||
self._drag_origin = (0.0, 0.0)
|
||||
self._drag_node_origin = (0.0, 0.0)
|
||||
self._node_items: Dict[str, tuple[int, ...]] = {}
|
||||
self._edge_items: Dict[str, tuple[int, ...]] = {}
|
||||
self._event_bus = EventBus.get()
|
||||
self._subscribed = False
|
||||
self._build_static_layout()
|
||||
self.update_data()
|
||||
self._subscribe_runtime_events()
|
||||
self._draw_graph(rebuild=True)
|
||||
self.after(20, self._restore_viewport)
|
||||
|
||||
def _build_static_layout(self):
|
||||
# ── 1. Top Section Header ──
|
||||
header_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header_frame.pack(fill="x", padx=20, pady=(16, 12))
|
||||
def destroy(self):
|
||||
if self._subscribed:
|
||||
for name in self._runtime_events():
|
||||
self._event_bus.unsubscribe(name, self._on_runtime_event)
|
||||
self._subscribed = False
|
||||
super().destroy()
|
||||
|
||||
left_titles = ctk.CTkFrame(header_frame, fg_color="transparent")
|
||||
left_titles.pack(side="left")
|
||||
@staticmethod
|
||||
def _runtime_events() -> tuple[str, ...]:
|
||||
return (
|
||||
EVENT_ROUTING_UPDATED,
|
||||
EVENT_QUOTA_UPDATED,
|
||||
EVENT_ACCOUNT_ADDED,
|
||||
EVENT_ACCOUNT_UPDATED,
|
||||
EVENT_ACCOUNT_REMOVED,
|
||||
EVENT_ACCOUNT_AUTH_CHANGED,
|
||||
)
|
||||
|
||||
ctk.CTkLabel(
|
||||
left_titles,
|
||||
text="Команда агентов",
|
||||
font=Theme.font_title_page(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w")
|
||||
def _subscribe_runtime_events(self) -> None:
|
||||
if self._subscribed:
|
||||
return
|
||||
for name in self._runtime_events():
|
||||
self._event_bus.subscribe(name, self._on_runtime_event)
|
||||
self._subscribed = True
|
||||
|
||||
ctk.CTkLabel(
|
||||
left_titles,
|
||||
text="Управляйте командой Hermes и их ролями",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
).pack(anchor="w", pady=(2, 0))
|
||||
def _build_static_layout(self) -> None:
|
||||
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(12, 8))
|
||||
titles = ctk.CTkFrame(header, fg_color="transparent")
|
||||
titles.pack(side="left")
|
||||
ctk.CTkLabel(titles, text="Граф маршрутизации", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
anchor="w"
|
||||
)
|
||||
self.state_label = ctk.CTkLabel(
|
||||
titles, text="Роли и реальные failover-цепочки", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
|
||||
)
|
||||
self.state_label.pack(anchor="w")
|
||||
actions = ctk.CTkFrame(header, fg_color="transparent")
|
||||
actions.pack(side="right")
|
||||
for text, command in (
|
||||
("↶", self._undo),
|
||||
("↷", self._redo),
|
||||
("Авто", self._auto_layout),
|
||||
("Вписать", self.fit_to_screen),
|
||||
("Сохранить", self._save),
|
||||
):
|
||||
HubButton(actions, text=text, variant="primary" if text == "Сохранить" else "secondary", command=command).pack(
|
||||
side="left", padx=3
|
||||
)
|
||||
|
||||
right_actions = ctk.CTkFrame(header_frame, fg_color="transparent")
|
||||
right_actions.pack(side="right")
|
||||
toolbar = ctk.CTkFrame(self, fg_color=Theme.SURFACE, corner_radius=Theme.RADIUS_SM)
|
||||
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, 7))
|
||||
self.search = ctk.CTkEntry(toolbar, placeholder_text="Найти роль или профиль…", width=250)
|
||||
self.search.pack(side="left", padx=8, pady=6)
|
||||
self.search.bind("<Return>", self._search)
|
||||
ctk.CTkLabel(toolbar, text="Связь", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(
|
||||
side="left", padx=(8, 4)
|
||||
)
|
||||
self.edge_type = ctk.CTkOptionMenu(toolbar, values=list(EDGE_TYPES), width=112)
|
||||
self.edge_type.set("DELEGATE")
|
||||
self.edge_type.pack(side="left", pady=6)
|
||||
roles = [node.role_id for node in self.controller.graph.nodes] or ["orchestrator"]
|
||||
self.edge_target = ctk.CTkOptionMenu(toolbar, values=roles, width=135)
|
||||
self.edge_target.set(next((role for role in roles if role != self.selected_role), roles[0]))
|
||||
self.edge_target.pack(side="left", padx=(5, 0), pady=6)
|
||||
profile_ids = list(load_router_config().profiles) or ["—"]
|
||||
self.edge_profile = ctk.CTkOptionMenu(toolbar, values=["—", *profile_ids], width=140)
|
||||
self.edge_profile.set("—")
|
||||
self.edge_profile.pack(side="left", padx=(5, 0), pady=6)
|
||||
HubButton(toolbar, text="Соединить", variant="secondary", command=self._connect_selected).pack(
|
||||
side="left", padx=5
|
||||
)
|
||||
HubButton(toolbar, text="Изменить", variant="secondary", command=self._change_selected_edge).pack(
|
||||
side="left", padx=(0, 5)
|
||||
)
|
||||
self.zoom_label = ctk.CTkLabel(toolbar, text="100%", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.zoom_label.pack(side="right", padx=10)
|
||||
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
body.grid_rowconfigure(0, weight=1)
|
||||
body.grid_columnconfigure(0, weight=4)
|
||||
body.grid_columnconfigure(1, weight=1)
|
||||
canvas_frame = ctk.CTkFrame(body, fg_color=Theme.SURFACE_MUTED, border_width=1, border_color=Theme.BORDER)
|
||||
canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||
self.canvas = tk.Canvas(
|
||||
canvas_frame,
|
||||
bg=Theme.SURFACE_MUTED,
|
||||
highlightthickness=0,
|
||||
xscrollincrement=1,
|
||||
yscrollincrement=1,
|
||||
scrollregion=(0, 0, 1800, 1200),
|
||||
)
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
self.canvas.bind("<ButtonPress-1>", self._on_press)
|
||||
self.canvas.bind("<B1-Motion>", self._on_drag)
|
||||
self.canvas.bind("<ButtonRelease-1>", self._on_release)
|
||||
self.canvas.bind("<MouseWheel>", self._on_zoom)
|
||||
self.canvas.bind("<ButtonPress-2>", self._start_pan)
|
||||
self.canvas.bind("<B2-Motion>", self._pan)
|
||||
self.canvas.bind("<ButtonRelease-2>", self._end_pan)
|
||||
self.canvas.bind("<Control-z>", lambda _e: self._undo())
|
||||
self.canvas.bind("<Control-y>", lambda _e: self._redo())
|
||||
self.canvas.bind("<Delete>", self._delete_selected_edge)
|
||||
self.canvas.focus_set()
|
||||
|
||||
self.minimap = tk.Canvas(canvas_frame, width=155, height=95, bg=Theme.SURFACE, highlightthickness=1)
|
||||
self.minimap.place(relx=1.0, rely=1.0, x=-12, y=-12, anchor="se")
|
||||
|
||||
self.inspector = ctk.CTkFrame(body, fg_color=Theme.SURFACE, border_width=1, border_color=Theme.BORDER)
|
||||
self.inspector.grid(row=0, column=1, sticky="nsew")
|
||||
self.inspector_title = ctk.CTkLabel(
|
||||
self.inspector, text="Инспектор роли", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY
|
||||
)
|
||||
self.inspector_title.pack(anchor="w", padx=12, pady=(12, 2))
|
||||
self.inspector_status = ctk.CTkLabel(
|
||||
self.inspector, text="", justify="left", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
|
||||
)
|
||||
self.inspector_status.pack(fill="x", padx=12, pady=(0, 8))
|
||||
self.chain_frame = ctk.CTkScrollableFrame(self.inspector, fg_color="transparent")
|
||||
self.chain_frame.pack(fill="both", expand=True, padx=8)
|
||||
HubButton(
|
||||
right_actions,
|
||||
text="+ Добавить агент",
|
||||
self.inspector,
|
||||
text="Открыть маршрутизацию",
|
||||
variant="primary",
|
||||
height=Theme.HEIGHT_BTN_MD,
|
||||
command=lambda: self._trigger_action("add_account", {}),
|
||||
).pack(side="left", padx=(0, 8))
|
||||
command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}),
|
||||
).pack(fill="x", padx=10, pady=10)
|
||||
|
||||
ctk.CTkButton(
|
||||
right_actions,
|
||||
text="⋮",
|
||||
width=38,
|
||||
height=Theme.HEIGHT_BTN_MD,
|
||||
fg_color=Theme.SURFACE,
|
||||
hover_color=Theme.SURFACE_HOVER,
|
||||
def _world(self, x: float, y: float) -> tuple[float, float]:
|
||||
zoom = self.controller.graph.zoom
|
||||
return self.canvas.canvasx(x) / zoom, self.canvas.canvasy(y) / zoom
|
||||
|
||||
def _on_press(self, event: Any) -> None:
|
||||
item = self.canvas.find_closest(self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))
|
||||
tags = self.canvas.gettags(item)
|
||||
edge = next((tag[5:] for tag in tags if tag.startswith("edge:")), "")
|
||||
if edge:
|
||||
self.selected_edge = edge
|
||||
selected = next((item for item in self.controller.graph.edges if item.edge_id == edge), None)
|
||||
if selected:
|
||||
self.edge_type.set(selected.edge_type)
|
||||
self.edge_target.set(selected.target)
|
||||
self.edge_profile.set(selected.profile_id or "—")
|
||||
self._draw_graph(rebuild=True)
|
||||
return
|
||||
role = next((tag[5:] for tag in tags if tag.startswith("role:")), "")
|
||||
if role:
|
||||
self.selected_role = role
|
||||
self.selected_edge = ""
|
||||
self._drag_role = role
|
||||
self._drag_origin = self._world(event.x, event.y)
|
||||
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
|
||||
self._drag_node_origin = (node.x, node.y) if node else (0.0, 0.0)
|
||||
self._update_inspector()
|
||||
self._update_live_styles()
|
||||
|
||||
def _on_drag(self, event: Any) -> None:
|
||||
if not self._drag_role:
|
||||
return
|
||||
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
|
||||
if not node:
|
||||
return
|
||||
x, y = self._world(event.x, event.y)
|
||||
dx, dy = x - self._drag_origin[0], y - self._drag_origin[1]
|
||||
self._drag_origin = (x, y)
|
||||
node.x += dx
|
||||
node.y += dy
|
||||
self.controller.dirty = True
|
||||
self._draw_graph(rebuild=True)
|
||||
|
||||
def _on_release(self, _event: Any) -> None:
|
||||
if self._drag_role:
|
||||
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
|
||||
if node:
|
||||
final_x, final_y = node.x, node.y
|
||||
node.x, node.y = self._drag_node_origin
|
||||
self.controller.move_node(node.role_id, final_x, final_y)
|
||||
self._drag_role = ""
|
||||
self._set_dirty_text()
|
||||
|
||||
def _on_zoom(self, event: Any) -> str:
|
||||
factor = 1.1 if event.delta > 0 else 0.9
|
||||
self.controller.graph.zoom = max(0.45, min(1.8, self.controller.graph.zoom * factor))
|
||||
self.controller.dirty = True
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
return "break"
|
||||
|
||||
def _start_pan(self, event: Any) -> None:
|
||||
self.canvas.scan_mark(event.x, event.y)
|
||||
|
||||
def _pan(self, event: Any) -> None:
|
||||
self.canvas.scan_dragto(event.x, event.y, gain=1)
|
||||
|
||||
def _end_pan(self, _event: Any) -> None:
|
||||
zoom = self.controller.graph.zoom
|
||||
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
|
||||
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
|
||||
self.controller.dirty = True
|
||||
self._set_dirty_text()
|
||||
|
||||
def _restore_viewport(self) -> None:
|
||||
graph = self.controller.graph
|
||||
z = graph.zoom
|
||||
self.canvas.xview_moveto(max(0.0, graph.viewport_x * z / 1800.0))
|
||||
self.canvas.yview_moveto(max(0.0, graph.viewport_y * z / 1200.0))
|
||||
|
||||
def _node_coords(self, role_id: str) -> tuple[float, float, float, float]:
|
||||
node = next(item for item in self.controller.graph.nodes if item.role_id == role_id)
|
||||
z = self.controller.graph.zoom
|
||||
return node.x * z, node.y * z, (node.x + self.NODE_W) * z, (node.y + self.NODE_H) * z
|
||||
|
||||
def _draw_graph(self, rebuild: bool = True) -> None:
|
||||
if rebuild:
|
||||
self.canvas.delete("all")
|
||||
self._node_items.clear()
|
||||
self._edge_items.clear()
|
||||
z = self.controller.graph.zoom
|
||||
for edge in self.controller.graph.edges:
|
||||
try:
|
||||
sx1, sy1, sx2, sy2 = self._node_coords(edge.source)
|
||||
tx1, ty1, _tx2, ty2 = self._node_coords(edge.target)
|
||||
except StopIteration:
|
||||
continue
|
||||
start = (sx2, (sy1 + sy2) / 2)
|
||||
end = (tx1, (ty1 + ty2) / 2)
|
||||
dash = () if edge.edge_type == "PRIMARY" else (7, 4) if edge.edge_type == "FALLBACK" else (2, 4)
|
||||
width = 3 if edge.edge_type == "PRIMARY" else 2
|
||||
line = self.canvas.create_line(
|
||||
*start,
|
||||
*end,
|
||||
smooth=True,
|
||||
arrow="last",
|
||||
width=width,
|
||||
dash=dash,
|
||||
fill=Theme.ACCENT if edge.edge_id == self.selected_edge else Theme.TEXT_ACCENT,
|
||||
tags=(f"edge:{edge.edge_id}", "edge"),
|
||||
)
|
||||
label = self.canvas.create_text(
|
||||
(start[0] + end[0]) / 2,
|
||||
(start[1] + end[1]) / 2 - 9,
|
||||
text=edge.edge_type,
|
||||
fill=Theme.TEXT_MUTED,
|
||||
font=("Segoe UI", max(7, int(8 * z)), "bold"),
|
||||
tags=(f"edge:{edge.edge_id}", "edge"),
|
||||
)
|
||||
self._edge_items[edge.edge_id] = (line, label)
|
||||
config = load_router_config()
|
||||
for node in self.controller.graph.nodes:
|
||||
x1, y1, x2, y2 = self._node_coords(node.role_id)
|
||||
pipeline = self._pipeline_for(node.role_id)
|
||||
active = pipeline.active_profile_id if pipeline else ""
|
||||
chain = list(config.roles.get(node.role_id).preferred_chain) if node.role_id in config.roles else []
|
||||
selected = node.role_id == self.selected_role
|
||||
rect = self.canvas.create_rectangle(
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
width=2 if selected else 1,
|
||||
outline=Theme.ACCENT if selected else Theme.BORDER,
|
||||
fill=Theme.SURFACE,
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
title = self.canvas.create_text(
|
||||
x1 + 12 * z,
|
||||
y1 + 17 * z,
|
||||
anchor="w",
|
||||
text=node.label or node.role_id,
|
||||
fill=Theme.TEXT_PRIMARY,
|
||||
font=("Segoe UI", max(9, int(11 * z)), "bold"),
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
active_text = f"Активен: {active}" if active else "Активный профиль: Н/Д"
|
||||
meta = self.canvas.create_text(
|
||||
x1 + 12 * z,
|
||||
y1 + 43 * z,
|
||||
anchor="w",
|
||||
text=active_text,
|
||||
fill=Theme.STATUS_HEALTHY if active else Theme.TEXT_MUTED,
|
||||
font=("Segoe UI", max(7, int(8 * z))),
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
chain_text = " → ".join(chain[:3]) if chain else "Нет профилей"
|
||||
chain_item = self.canvas.create_text(
|
||||
x1 + 12 * z,
|
||||
y1 + 68 * z,
|
||||
anchor="w",
|
||||
width=(self.NODE_W - 24) * z,
|
||||
text=chain_text,
|
||||
fill=Theme.TEXT_SECONDARY,
|
||||
font=("Segoe UI", max(7, int(8 * z))),
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
self._node_items[node.role_id] = (rect, title, meta, chain_item)
|
||||
self.zoom_label.configure(text=f"{self.controller.graph.zoom * 100:.0f}%")
|
||||
bounds = self.canvas.bbox("all")
|
||||
if bounds:
|
||||
self.canvas.configure(scrollregion=(0, 0, max(1800, bounds[2] + 120), max(1200, bounds[3] + 120)))
|
||||
self._draw_minimap()
|
||||
self._update_inspector()
|
||||
|
||||
def _draw_minimap(self) -> None:
|
||||
self.minimap.delete("all")
|
||||
if not self.controller.graph.nodes:
|
||||
return
|
||||
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
|
||||
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
|
||||
scale = min(145 / max(max_x, 1), 85 / max(max_y, 1))
|
||||
for node in self.controller.graph.nodes:
|
||||
self.minimap.create_rectangle(
|
||||
5 + node.x * scale,
|
||||
5 + node.y * scale,
|
||||
5 + (node.x + self.NODE_W) * scale,
|
||||
5 + (node.y + self.NODE_H) * scale,
|
||||
outline=Theme.ACCENT if node.role_id == self.selected_role else Theme.BORDER,
|
||||
fill=Theme.SURFACE_MUTED,
|
||||
)
|
||||
|
||||
def _update_inspector(self) -> None:
|
||||
for child in self.chain_frame.winfo_children():
|
||||
child.destroy()
|
||||
role = self.selected_role
|
||||
pipeline = self._pipeline_for(role)
|
||||
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
|
||||
self.inspector_title.configure(text=node.label if node else role)
|
||||
self.inspector_status.configure(
|
||||
text=f"Активный: {pipeline.active_profile_id if pipeline and pipeline.active_profile_id else 'Н/Д'}"
|
||||
)
|
||||
config = load_router_config()
|
||||
policy = config.roles.get(role)
|
||||
chain = list(policy.preferred_chain) if policy else []
|
||||
live_nodes = {item.profile_id: item for item in pipeline.nodes} if pipeline else {}
|
||||
for index, profile_id in enumerate(chain):
|
||||
profile = config.profiles.get(profile_id)
|
||||
live = live_nodes.get(profile_id)
|
||||
card = HubCard(self.chain_frame)
|
||||
card.pack(fill="x", pady=4)
|
||||
rank = "PRIMARY" if index == 0 else f"FALLBACK {index}"
|
||||
ctk.CTkLabel(card, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(
|
||||
anchor="w", padx=8, pady=(6, 0)
|
||||
)
|
||||
ctk.CTkLabel(
|
||||
card, text=profile_id, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(anchor="w", padx=8)
|
||||
provider = profile.provider if profile else "Н/Д"
|
||||
model = live.model if live else (profile.preferred_models[0] if profile and profile.preferred_models else "Н/Д")
|
||||
identity = live.account_identity if live and live.account_identity else "Аккаунт: Н/Д"
|
||||
quota = live.quota_status if live else "Н/Д"
|
||||
if profile_id in self._quota_overrides:
|
||||
raw_quota = self._quota_overrides[profile_id]
|
||||
quota = getattr(raw_quota, "status", None) or getattr(raw_quota, "quota_status", None) or quota
|
||||
if str(quota).strip().lower() in {"", "unknown", "none", "not_configured"}:
|
||||
quota = "Н/Д"
|
||||
reason = live.failover_reason if live and live.failover_reason else "Н/Д"
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text=f"{provider} • {model}\n{identity}\nКвота: {quota}\nFailover: {reason}",
|
||||
justify="left",
|
||||
anchor="w",
|
||||
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,
|
||||
font=("Segoe UI", 14, "bold"),
|
||||
corner_radius=Theme.RADIUS_SM,
|
||||
command=lambda: self._trigger_action("auto_assign_all", {}),
|
||||
).pack(side="left")
|
||||
|
||||
# ── Scrollable Body ──
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=15, pady=(0, 8))
|
||||
|
||||
# ── 2. Top 4 Metric Cards (Real Readiness) ──
|
||||
metrics_grid = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
metrics_grid.pack(fill="x", pady=(0, 16))
|
||||
for i in range(4):
|
||||
metrics_grid.grid_columnconfigure(i, weight=1)
|
||||
|
||||
self.m1 = HubMetricCard(
|
||||
metrics_grid, title="АГЕНТЫ", value="0/6", subtext="готовы к работе", icon="👥", accent=True
|
||||
)
|
||||
self.m1.grid(row=0, column=0, padx=6, sticky="nsew")
|
||||
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")
|
||||
|
||||
self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼")
|
||||
self.m2.grid(row=0, column=1, padx=6, sticky="nsew")
|
||||
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()
|
||||
|
||||
self.m3 = HubMetricCard(metrics_grid, title="ПРОВАЙДЕРЫ", value="3/3", subtext="доступно", icon="⚛")
|
||||
self.m3.grid(row=0, column=2, padx=6, sticky="nsew")
|
||||
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)
|
||||
|
||||
self.m4 = HubMetricCard(
|
||||
metrics_grid, title="СОСТОЯНИЕ", value="Healthy", subtext="Все системы работают", icon="🛡️"
|
||||
)
|
||||
self.m4.grid(row=0, column=3, padx=6, sticky="nsew")
|
||||
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)
|
||||
|
||||
# ── 3. Hierarchy: orchestrator → role agents ──
|
||||
ctk.CTkLabel(
|
||||
self.scroll,
|
||||
text="ОРКЕСТРАТОР",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
).pack(anchor="w", padx=Theme.SPACE_XS)
|
||||
self.orchestrator_grid = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
self.orchestrator_grid.pack(fill="x", pady=(Theme.SPACE_XS, Theme.SECTION_GAP))
|
||||
self.orchestrator_grid.grid_columnconfigure(0, weight=1)
|
||||
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)
|
||||
|
||||
ctk.CTkLabel(
|
||||
self.scroll,
|
||||
text="РОЛИ И АГЕНТЫ",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
).pack(anchor="w", padx=Theme.SPACE_XS)
|
||||
self.cards_grid = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
self.cards_grid.pack(fill="both", expand=True)
|
||||
for col_idx in range(3):
|
||||
self.cards_grid.grid_columnconfigure(col_idx, weight=1)
|
||||
def _on_runtime_event(self, name: str, data: Any) -> None:
|
||||
# EventBus may publish from a worker. Tk mutation is always marshalled.
|
||||
try:
|
||||
self.after(0, lambda: self._apply_runtime_event(name, data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_data(self, snapshot: Optional[Any] = None):
|
||||
def _apply_runtime_event(self, name: str, data: Any) -> None:
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
if name == EVENT_ROUTING_UPDATED and payload.get("pipeline") is not None:
|
||||
self._live_pipelines[str(payload.get("role_id", ""))] = payload["pipeline"]
|
||||
elif name == EVENT_QUOTA_UPDATED and payload.get("profile_id"):
|
||||
self._quota_overrides[str(payload["profile_id"])] = payload.get("quota_snapshot") or payload.get("snapshot")
|
||||
self._update_live_styles()
|
||||
|
||||
def _pipeline_for(self, role_id: str) -> Any:
|
||||
if role_id in self._live_pipelines:
|
||||
return self._live_pipelines[role_id]
|
||||
return self.snapshot.routing.get(role_id) if self.snapshot else None
|
||||
|
||||
def _update_live_styles(self) -> None:
|
||||
"""Update existing canvas items; runtime events never rebuild the canvas."""
|
||||
if not self.snapshot:
|
||||
return
|
||||
for role_id, items in self._node_items.items():
|
||||
pipeline = self._pipeline_for(role_id)
|
||||
active = pipeline.active_profile_id if pipeline else ""
|
||||
self.canvas.itemconfigure(items[0], outline=Theme.ACCENT if role_id == self.selected_role else Theme.BORDER)
|
||||
self.canvas.itemconfigure(items[2], text=f"Активен: {active}" if active else "Активный профиль: Н/Д")
|
||||
self._update_inspector()
|
||||
|
||||
def update_data(self, snapshot: Optional[Any] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
self.snapshot = snapshot
|
||||
self._live_pipelines = {
|
||||
role_id: pipeline
|
||||
for role_id, pipeline in self._live_pipelines.items()
|
||||
if role_id in snapshot.routing and pipeline is not snapshot.routing[role_id]
|
||||
}
|
||||
if set(snapshot.routing) != set(self._node_items):
|
||||
self._draw_graph(rebuild=True)
|
||||
else:
|
||||
self._update_live_styles()
|
||||
|
||||
readiness = snapshot.readiness
|
||||
agents = snapshot.agents
|
||||
def _search(self, _event: Any = None) -> str:
|
||||
query = self.search.get().strip().lower()
|
||||
config = load_router_config()
|
||||
for node in self.controller.graph.nodes:
|
||||
chain = config.roles.get(node.role_id).preferred_chain if node.role_id in config.roles else []
|
||||
if query in node.role_id.lower() or query in node.label.lower() or any(query in item.lower() for item in chain):
|
||||
self.selected_role = node.role_id
|
||||
self._draw_graph(rebuild=True)
|
||||
break
|
||||
return "break"
|
||||
|
||||
# Update metric cards
|
||||
self.m1.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}")
|
||||
self.m1.sub_label.configure(text="ролей готовы")
|
||||
def focus_role(self, role_id: str) -> None:
|
||||
"""Select a role when Routing delegates editing to this single editor."""
|
||||
if not any(node.role_id == role_id for node in self.controller.graph.nodes):
|
||||
self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING)
|
||||
return
|
||||
self.selected_role = role_id
|
||||
self.selected_edge = ""
|
||||
target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id)
|
||||
self.edge_target.set(target)
|
||||
self._draw_graph(rebuild=True)
|
||||
self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT)
|
||||
|
||||
self.m2.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}")
|
||||
self.m2.sub_label.configure(text="подключено")
|
||||
def _connect_selected(self) -> None:
|
||||
if len(self.controller.graph.nodes) < 2:
|
||||
return
|
||||
source = self.selected_role
|
||||
target = self.edge_target.get()
|
||||
profile_id = self.edge_profile.get()
|
||||
profile_id = "" if profile_id == "—" else profile_id
|
||||
ok, message = self.controller.add_edge(source, target, self.edge_type.get(), profile_id)
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
self.m3.val_label.configure(text=f"{readiness.providers_ready_count}/{readiness.total_providers}")
|
||||
self.m3.sub_label.configure(text="доступно")
|
||||
def _change_selected_edge(self) -> None:
|
||||
if not self.selected_edge:
|
||||
self.state_label.configure(text="Сначала выберите связь на графе", text_color=Theme.STATUS_WARNING)
|
||||
return
|
||||
profile_id = self.edge_profile.get()
|
||||
ok, message = self.controller.set_edge_type(
|
||||
self.selected_edge,
|
||||
self.edge_type.get(),
|
||||
"" if profile_id == "—" else profile_id,
|
||||
)
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
self.m4.val_label.configure(text=readiness.title_ru)
|
||||
self.m4.sub_label.configure(text=readiness.summary_ru)
|
||||
def _delete_selected_edge(self, _event: Any = None) -> str:
|
||||
selected = self.canvas.find_withtag("current")
|
||||
edge_id = self.selected_edge
|
||||
if selected:
|
||||
edge_id = next((tag[5:] for tag in self.canvas.gettags(selected[0]) if tag.startswith("edge:")), "")
|
||||
if edge_id:
|
||||
self.controller.delete_edge(edge_id)
|
||||
self.selected_edge = ""
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
return "break"
|
||||
|
||||
live_roles = {agent.role_id for agent in agents}
|
||||
for role_id in list(self._card_widgets):
|
||||
if role_id not in live_roles:
|
||||
self._card_widgets.pop(role_id).destroy()
|
||||
def _auto_layout(self) -> None:
|
||||
self.controller.auto_layout()
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
orchestrators = [agent for agent in agents if agent.is_main_orchestrator]
|
||||
role_agents = [agent for agent in agents if not agent.is_main_orchestrator]
|
||||
for index, agent in enumerate(orchestrators):
|
||||
card = self._card_widgets.get(agent.role_id)
|
||||
if card is None:
|
||||
card = AgentCardWidget(self.orchestrator_grid, on_action=self.on_action)
|
||||
self._card_widgets[agent.role_id] = card
|
||||
card.update_agent(agent)
|
||||
card.grid(row=index, column=0, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
|
||||
def _undo(self) -> None:
|
||||
if self.controller.undo():
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
for index, agent in enumerate(role_agents):
|
||||
card = self._card_widgets.get(agent.role_id)
|
||||
if card is None:
|
||||
card = AgentCardWidget(self.cards_grid, on_action=self.on_action)
|
||||
self._card_widgets[agent.role_id] = card
|
||||
card.update_agent(agent)
|
||||
card.grid(row=index // 3, column=index % 3, padx=6, pady=6, sticky="nsew")
|
||||
def _redo(self) -> None:
|
||||
if self.controller.redo():
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _trigger_action(self, action: str, profile: Dict[str, Any]):
|
||||
def fit_to_screen(self) -> None:
|
||||
if not self.controller.graph.nodes:
|
||||
return
|
||||
self.update_idletasks()
|
||||
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
|
||||
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
|
||||
self.controller.graph.zoom = max(0.45, min(1.4, min(self.canvas.winfo_width() / max_x, self.canvas.winfo_height() / max_y) * 0.9))
|
||||
self.controller.dirty = True
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _save(self) -> None:
|
||||
zoom = self.controller.graph.zoom
|
||||
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
|
||||
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
|
||||
issues = self.controller.save()
|
||||
if issues:
|
||||
self._show_issues(issues)
|
||||
return
|
||||
self.state_label.configure(text="Сохранено • позиции и масштаб переживут перезапуск", text_color=Theme.STATUS_HEALTHY)
|
||||
|
||||
def _show_issues(self, issues: List[GraphIssue]) -> None:
|
||||
bad_nodes = {issue.node_id for issue in issues if issue.node_id}
|
||||
for role_id, items in self._node_items.items():
|
||||
self.canvas.itemconfigure(items[0], outline=Theme.STATUS_ERROR if role_id in bad_nodes else Theme.BORDER)
|
||||
message = " • ".join(issue.message for issue in issues[:3])
|
||||
if len(issues) > 3:
|
||||
message += f" • ещё {len(issues) - 3}"
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_ERROR)
|
||||
|
||||
def _set_dirty_text(self) -> None:
|
||||
self.state_label.configure(
|
||||
text="● Есть несохранённые изменения" if self.controller.dirty else "Роли и реальные failover-цепочки",
|
||||
text_color=Theme.STATUS_WARNING if self.controller.dirty else Theme.TEXT_MUTED,
|
||||
)
|
||||
|
||||
def _trigger_action(self, action: str, profile: Dict[str, Any]) -> None:
|
||||
if self.on_action:
|
||||
self.on_action(action, profile)
|
||||
|
|
|
|||
|
|
@ -511,10 +511,17 @@ class UnifiedHealthService:
|
|||
dead_roles = 0
|
||||
warnings: List[str] = []
|
||||
|
||||
total_accounts = sum(len(profs) for profs in profiles_by_prov.values())
|
||||
connected_accounts = sum(
|
||||
1 for profs in profiles_by_prov.values() for p in profs if p.auth_state == "AUTHENTICATED"
|
||||
)
|
||||
# Empty/cold placeholders are capacity for future accounts, not broken
|
||||
# accounts. They remain visible in provider slot counts but must not
|
||||
# downgrade a system whose configured routes are all operational.
|
||||
configured_profiles = [
|
||||
profile
|
||||
for profiles in profiles_by_prov.values()
|
||||
for profile in profiles
|
||||
if not profile.is_empty_slot
|
||||
]
|
||||
total_accounts = len(configured_profiles)
|
||||
connected_accounts = sum(1 for profile in configured_profiles if profile.auth_state == "AUTHENTICATED")
|
||||
|
||||
providers_online = sum(
|
||||
1 for profs in profiles_by_prov.values() if any(p.health_state == STATUS_HEALTHY for p in profs)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
|
@ -187,13 +189,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 +220,140 @@ 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_antigravity_grouped_summary_includes_five_hour_and_weekly_buckets():
|
||||
service = AccountQuotaService()
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value.read.return_value = json.dumps(
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"displayName": "Gemini Models",
|
||||
"buckets": [
|
||||
{
|
||||
"bucketId": "gemini-weekly",
|
||||
"window": "weekly",
|
||||
"remainingFraction": 0.73,
|
||||
"resetTime": "2026-08-27T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"bucketId": "gemini-5h",
|
||||
"window": "5h",
|
||||
"remainingFraction": 0.91,
|
||||
"resetTime": "2026-08-23T05:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"displayName": "Claude and GPT models",
|
||||
"buckets": [
|
||||
{
|
||||
"bucketId": "3p-weekly",
|
||||
"window": "weekly",
|
||||
"remainingFraction": 0.44,
|
||||
"resetTime": "2026-08-27T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"bucketId": "3p-5h",
|
||||
"window": "5h",
|
||||
"remainingFraction": 0.82,
|
||||
"resetTime": "2026-08-23T05:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
).encode()
|
||||
|
||||
with patch(
|
||||
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
|
||||
) as urlopen:
|
||||
snapshot = service._collect_antigravity_quota(
|
||||
"ag-w1", {"token": {"access_token": "access-token"}, "project_id": "project-1"}
|
||||
)
|
||||
|
||||
assert urlopen.call_count == 1
|
||||
assert [bucket.id for bucket in snapshot.buckets] == [
|
||||
"antigravity.claude.5h",
|
||||
"antigravity.gemini.5h",
|
||||
"antigravity.claude.7d",
|
||||
"antigravity.gemini.7d",
|
||||
]
|
||||
assert [bucket.remaining_percent for bucket in snapshot.buckets] == [82.0, 91.0, 44.0, 73.0]
|
||||
assert snapshot.get_bucket_for_model("gemini-3.1-pro").remaining_percent == 73.0
|
||||
assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 44.0
|
||||
|
||||
|
||||
def test_antigravity_refreshes_expired_token_before_project_discovery():
|
||||
service = AccountQuotaService()
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value.read.return_value = json.dumps(
|
||||
{
|
||||
"models": {
|
||||
"claude-sonnet-4-6": {
|
||||
"quotaInfo": {"remainingFraction": 0.64, "resetTime": "2026-08-23T00:00:00Z"}
|
||||
},
|
||||
"gemini-3.7-flash": {
|
||||
"quotaInfo": {"remainingFraction": 0.91, "resetTime": "2026-08-23T00:00:00Z"}
|
||||
},
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
auth_data = {
|
||||
"token": {
|
||||
"access_token": "expired-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": "2026-08-22T00:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"antigravity_provider.oauth.refresh_access_token",
|
||||
return_value={"access_token": "fresh-token", "expires_at": "2026-08-23T00:00:00Z"},
|
||||
) as refresh, patch(
|
||||
"antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"
|
||||
) as discover, patch(
|
||||
"antigravity_provider.router.quota_collector.ProfileAuthManager.save_profile_auth"
|
||||
) as save, patch(
|
||||
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
|
||||
) as urlopen:
|
||||
snapshot = service._collect_antigravity_quota("ag-w1", auth_data)
|
||||
|
||||
refresh.assert_called_once_with("refresh-token")
|
||||
discover.assert_called_once_with("fresh-token")
|
||||
assert "Bearer fresh-token" == urlopen.call_args.args[0].headers["Authorization"]
|
||||
assert save.called
|
||||
assert auth_data["project_id"] == "project-1"
|
||||
assert snapshot.source == "provider_api"
|
||||
assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 64.0
|
||||
|
||||
|
||||
def test_opencode_shows_published_limits_and_subscription_error():
|
||||
service = AccountQuotaService()
|
||||
models_response = MagicMock()
|
||||
models_response.__enter__.return_value.read.return_value = b'{"data": []}'
|
||||
entitlement_error = urllib.error.HTTPError(
|
||||
"https://opencode.ai/zen/go/v1/usage",
|
||||
403,
|
||||
"Forbidden",
|
||||
{},
|
||||
io.BytesIO(b'{"message":"OpenCode Go subscription required"}'),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"antigravity_provider.router.quota_collector.urllib.request.urlopen",
|
||||
side_effect=[models_response, entitlement_error],
|
||||
):
|
||||
snapshot = service._collect_opencode_quota("opengo-1", {"api_key": "test-key"})
|
||||
|
||||
assert snapshot.source == "provider_api"
|
||||
assert snapshot.unavailable_reason == "Для этого ключа не активна подписка OpenCode Go"
|
||||
assert [bucket.limit_absolute for bucket in snapshot.buckets] == [12, 30, 60]
|
||||
assert [bucket.period for bucket in snapshot.buckets] == ["5h", "7d", "30d"]
|
||||
assert all(bucket.remaining_percent is None for bucket in snapshot.buckets)
|
||||
|
||||
|
||||
def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path):
|
||||
|
|
@ -216,6 +369,17 @@ def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_
|
|||
|
||||
# Gemini should remain healthy!
|
||||
assert tracker.is_healthy("ag-w1", "gemini-2.5-pro") is True
|
||||
|
||||
|
||||
def test_live_measured_quota_clears_stale_exhaustion(tmp_path):
|
||||
tracker = HealthTracker(state_file=tmp_path / "router_state.json")
|
||||
tracker.mark_quota_exhausted("ag-w1", "gemini-3.1-pro", duration=3600, reason="old 429")
|
||||
assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is False
|
||||
|
||||
changed = tracker.reconcile_measured_quota("ag-w1", {"gemini": 73.0, "claude": 44.0})
|
||||
|
||||
assert changed is True
|
||||
assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is True
|
||||
assert tracker.is_healthy("ag-w1", "gemini-2.5-flash") is True
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -95,6 +97,29 @@ def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None:
|
|||
card.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_live_quota_overrides_stale_exhausted_card_status(ui_root) -> None:
|
||||
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
|
||||
profile = replace(_profile(), health_state="quota_exhausted", health_label_ru="Квота исчерпана")
|
||||
snapshot = QuotaSnapshot(
|
||||
account_id="account-1",
|
||||
provider="antigravity",
|
||||
source="provider_api",
|
||||
buckets=[
|
||||
QuotaBucket(id="claude", display_name="Claude", remaining_percent=100.0),
|
||||
QuotaBucket(id="gemini", display_name="Gemini", remaining_percent=100.0),
|
||||
],
|
||||
)
|
||||
try:
|
||||
card.pack()
|
||||
card.update_account(profile, snapshot)
|
||||
ui_root.update_idletasks()
|
||||
assert card.status.label.cget("text") == "Работает"
|
||||
assert card.status.dot.cget("text_color") == Theme.STATUS_HEALTHY
|
||||
finally:
|
||||
card.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None:
|
||||
widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h")
|
||||
|
|
@ -187,6 +212,58 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N
|
|||
team_card.destroy()
|
||||
|
||||
|
||||
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
|
||||
agent = AgentViewModel(
|
||||
role_id="coder-primary",
|
||||
role_name_ru="Кодер 1",
|
||||
role_description_ru="Основной кодер",
|
||||
assigned_profile_id="ag-w1",
|
||||
assigned_display_name="Primary",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
model="gemini-3.1-pro",
|
||||
account_identity="user@example.test",
|
||||
routing_position="Primary",
|
||||
status="healthy",
|
||||
status_label_ru="Работает",
|
||||
is_active=True,
|
||||
is_main_orchestrator=False,
|
||||
active_quota_status="healthy",
|
||||
active_quota_label="Осталось 73%",
|
||||
)
|
||||
snapshot = HubSnapshot(
|
||||
generation=1,
|
||||
seq=1,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={},
|
||||
all_profiles={},
|
||||
readiness=_readiness(),
|
||||
agents=[agent],
|
||||
providers=[],
|
||||
routing={},
|
||||
quotas={
|
||||
"ag-w1": QuotaSnapshot(
|
||||
account_id="ag-w1",
|
||||
provider="antigravity",
|
||||
source="provider_api",
|
||||
buckets=[
|
||||
QuotaBucket(
|
||||
id="antigravity.gemini.7d",
|
||||
display_name="Gemini • неделя",
|
||||
model_family="gemini",
|
||||
remaining_percent=73.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
label, percent = DashboardView._agent_quota_measurement(snapshot, agent)
|
||||
|
||||
assert label == "Осталось 73%"
|
||||
assert percent == 73.0
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
|
||||
snapshot = HubSnapshot(
|
||||
|
|
@ -215,6 +292,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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
328
tests/test_ui_routing_graph.py
Normal file
328
tests/test_ui_routing_graph.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
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,
|
||||
GraphNode,
|
||||
RoutingGraph,
|
||||
RoutingGraphController,
|
||||
RoutingGraphStore,
|
||||
default_graph,
|
||||
validate_graph,
|
||||
)
|
||||
|
||||
|
||||
def _config():
|
||||
profiles = {
|
||||
"orch": SimpleNamespace(provider="openai-codex", preferred_models=["gpt-5"]),
|
||||
"coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]),
|
||||
}
|
||||
roles = {
|
||||
"orchestrator": SimpleNamespace(preferred_chain=["orch"]),
|
||||
"coder-primary": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"coder-secondary": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"reviewer": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"research": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"fast": SimpleNamespace(preferred_chain=["coder"]),
|
||||
}
|
||||
return SimpleNamespace(roles=roles, profiles=profiles)
|
||||
|
||||
|
||||
def test_antigravity_agent_catalog_keeps_gemini_pro_choices():
|
||||
choices = app_module.AGENT_MODEL_OPTIONS["antigravity"]
|
||||
assert "gemini-3.1-pro" in choices
|
||||
assert "gemini-2.5-pro" in choices
|
||||
|
||||
|
||||
def test_default_graph_migrates_six_roles_without_changing_chains():
|
||||
config = _config()
|
||||
before = {key: list(value.preferred_chain) for key, value in config.roles.items()}
|
||||
graph = default_graph(config)
|
||||
assert {node.role_id for node in graph.nodes} == set(config.roles)
|
||||
assert {edge.edge_type for edge in graph.edges} == {"DELEGATE"}
|
||||
assert before == {key: value.preferred_chain for key, value in config.roles.items()}
|
||||
|
||||
|
||||
def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path):
|
||||
path = tmp_path / "routing_graph.json"
|
||||
store = RoutingGraphStore(path)
|
||||
graph = default_graph(_config())
|
||||
graph.nodes[0].x = 777
|
||||
graph.zoom = 1.35
|
||||
graph.viewport_x = 42
|
||||
store.save(graph)
|
||||
loaded = store.load(_config())
|
||||
assert loaded.nodes[0].x == 777
|
||||
assert loaded.zoom == 1.35
|
||||
assert loaded.viewport_x == 42
|
||||
assert loaded.schema_version == 1
|
||||
|
||||
|
||||
def test_validation_finds_cycle_unreachable_and_missing_profile():
|
||||
config = _config()
|
||||
config.roles["reviewer"].preferred_chain = ["ghost"]
|
||||
graph = RoutingGraph(
|
||||
nodes=[
|
||||
GraphNode("orchestrator", 0, 0),
|
||||
GraphNode("coder-primary", 1, 0),
|
||||
GraphNode("reviewer", 2, 0),
|
||||
],
|
||||
edges=[
|
||||
GraphEdge("orchestrator", "coder-primary"),
|
||||
GraphEdge("coder-primary", "orchestrator"),
|
||||
],
|
||||
)
|
||||
codes = {issue.code for issue in validate_graph(graph, config)}
|
||||
assert {"cycle", "unreachable", "missing-profile"} <= codes
|
||||
|
||||
|
||||
def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
monkeypatch.setattr(graph_module, "load_router_config", _config)
|
||||
monkeypatch.setattr(
|
||||
graph_module.AutoAssigner,
|
||||
"assign_profile_to_role",
|
||||
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
||||
)
|
||||
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
|
||||
ok, _message = controller.add_edge("orchestrator", "coder-primary", "FALLBACK", "orch")
|
||||
assert ok
|
||||
assert calls == [("orch", "coder-primary", False)]
|
||||
|
||||
|
||||
def test_undo_redo_and_dirty_state(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(graph_module, "load_router_config", _config)
|
||||
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
|
||||
original = controller.graph.nodes[0].x
|
||||
controller.move_node("orchestrator", original + 100, 50)
|
||||
assert controller.dirty
|
||||
assert controller.undo()
|
||||
assert controller.graph.nodes[0].x == original
|
||||
assert controller.redo()
|
||||
assert controller.graph.nodes[0].x == original + 100
|
||||
|
||||
|
||||
def test_graph_store_handles_twenty_nodes(tmp_path):
|
||||
graph = RoutingGraph(nodes=[GraphNode(f"role-{index}", index * 70, index * 35) for index in range(20)])
|
||||
store = RoutingGraphStore(tmp_path / "routing_graph.json")
|
||||
store.save(graph)
|
||||
assert len(store.load(_config()).nodes) == 20
|
||||
|
||||
|
||||
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
|
||||
config = _config()
|
||||
calls = []
|
||||
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
wizard_module.AutoAssigner,
|
||||
"assign_profile_to_role",
|
||||
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
||||
)
|
||||
assert wizard_module.ensure_profile_in_routing("orch")[0]
|
||||
assert calls == []
|
||||
monkeypatch.setattr(
|
||||
wizard_module.AutoAssigner,
|
||||
"get_display_name_and_role",
|
||||
lambda _profile: ("Новый кодер", "coder", "fallback"),
|
||||
)
|
||||
assert wizard_module.ensure_profile_in_routing("new-slot")[0]
|
||||
assert calls == [("new-slot", "coder", False)]
|
||||
|
||||
|
||||
def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch):
|
||||
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
|
||||
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
|
||||
|
||||
class Adapter:
|
||||
@staticmethod
|
||||
def health_check(_profile):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def invoke(*_args, **_kwargs):
|
||||
raise AssertionError("profile test must never invoke inference")
|
||||
|
||||
monkeypatch.setattr(app_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
|
||||
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
|
||||
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter())
|
||||
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
|
||||
|
||||
result = app_module.do_test_profile("antigravity", "connected")
|
||||
assert result["success"] is True
|
||||
assert "runtime" in result["response"]
|
||||
|
||||
|
||||
def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(wizard_module, "ensure_profile_in_routing", lambda _profile: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
wizard_module.EventLogService,
|
||||
"get",
|
||||
lambda: SimpleNamespace(log=lambda *args, **kwargs: calls.append(("log", args, kwargs))),
|
||||
)
|
||||
from antigravity_provider.router import router_engine
|
||||
|
||||
monkeypatch.setattr(
|
||||
router_engine,
|
||||
"get_router_engine",
|
||||
lambda: SimpleNamespace(health=SimpleNamespace(clear_cooldown=lambda profile: calls.append(("clear", profile)))),
|
||||
)
|
||||
fake = SimpleNamespace(
|
||||
target_slot="ag-orch-fallback",
|
||||
selected_provider="antigravity",
|
||||
discovered_identity="account",
|
||||
finish_status_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
|
||||
on_complete=lambda payload: calls.append(("complete", payload)),
|
||||
destroy=lambda: calls.append(("destroy",)),
|
||||
)
|
||||
wizard_module.AddAccountWizard._finish(fake)
|
||||
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
|
||||
|
|
@ -109,7 +109,7 @@ def test_system_readiness_calculation():
|
|||
assert isinstance(readiness, SystemReadiness)
|
||||
assert readiness.state in (READINESS_HEALTHY, READINESS_LIMITED, READINESS_DEGRADED, READINESS_CRITICAL)
|
||||
assert readiness.total_roles > 0
|
||||
assert readiness.total_accounts >= 16
|
||||
assert readiness.total_accounts >= readiness.accounts_connected_count
|
||||
assert readiness.title_ru
|
||||
assert readiness.summary_ru
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue