fix: complete B7 live usability flows
This commit is contained in:
parent
f0461bc539
commit
40b3466558
14 changed files with 1057 additions and 164 deletions
|
|
@ -172,6 +172,48 @@ class AutoAssigner:
|
|||
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
@staticmethod
|
||||
def ensure_profile_definition(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||
"""Persist a router profile for provider slots introduced by the UI.
|
||||
|
||||
Claude and Grok were added after the original static router profile
|
||||
list. Their OAuth credentials could therefore be saved successfully
|
||||
while role assignment failed because the profile did not exist in the
|
||||
router YAML.
|
||||
"""
|
||||
config = load_router_config()
|
||||
if profile_id in config.profiles:
|
||||
return True, "Профиль уже зарегистрирован"
|
||||
defaults = {
|
||||
"grok": (["grok-3", "grok-3-mini", "grok-2"], ["reasoning", "coding", "research"]),
|
||||
"claude": (
|
||||
["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
|
||||
["reasoning", "coding", "review"],
|
||||
),
|
||||
"opencode-go": (
|
||||
["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
|
||||
["coding", "research", "fast"],
|
||||
),
|
||||
"openai-codex": (["gpt-4o", "o3-mini", "codex"], ["coding", "reasoning"]),
|
||||
"antigravity": (
|
||||
["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
|
||||
["coding", "reasoning", "research", "fast"],
|
||||
),
|
||||
}
|
||||
models, capabilities = defaults.get(provider, (["default"], []))
|
||||
config.profiles[profile_id] = RouterProfileConfig(
|
||||
profile_id=profile_id,
|
||||
provider=provider,
|
||||
account_id=profile_id,
|
||||
capabilities=list(capabilities),
|
||||
preferred_models=list(models),
|
||||
enabled=True,
|
||||
max_concurrency=1,
|
||||
)
|
||||
if not save_router_config(config):
|
||||
return False, f"Не удалось сохранить профиль '{profile_id}' в конфигурации"
|
||||
return True, f"Профиль '{profile_id}' зарегистрирован"
|
||||
|
||||
@staticmethod
|
||||
def recommend_assignment(provider: str) -> Tuple[str, str, str]:
|
||||
"""Analyze team health and return (recommended_slot, role_title_ru, reason_ru)."""
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.paren
|
|||
if _p.exists() and _ps not in sys.path:
|
||||
sys.path.insert(0, _ps)
|
||||
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
from antigravity_provider.router.router_config import load_router_config, save_router_config
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
|
|
@ -61,7 +61,7 @@ from antigravity_provider.router.unified_health import (
|
|||
STATUS_HEALTHY,
|
||||
)
|
||||
|
||||
from antigravity_provider.router.ui.views.team_view import TeamView
|
||||
from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain
|
||||
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
|
||||
from antigravity_provider.router.ui.views.accounts_view import AccountsView
|
||||
from antigravity_provider.router.ui.views.providers_view import ProvidersView
|
||||
|
|
@ -231,6 +231,18 @@ class HermesHubApp(ctk.CTk):
|
|||
pass
|
||||
|
||||
self.after(50, self._refresh_data)
|
||||
self.after(800, self._refresh_quotas_on_startup)
|
||||
|
||||
def _refresh_quotas_on_startup(self) -> None:
|
||||
"""Populate measured quota cards immediately instead of after the 5-minute scheduler tick."""
|
||||
if self._shutting_down:
|
||||
return
|
||||
try:
|
||||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||
|
||||
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
|
||||
except Exception as exc:
|
||||
logger.warning("Initial quota refresh could not start: %s", exc)
|
||||
|
||||
def _build_layout(self):
|
||||
# ── Sidebar (Left) ──
|
||||
|
|
@ -263,8 +275,8 @@ class HermesHubApp(ctk.CTk):
|
|||
("team", "Команда", "team"),
|
||||
("accounts", "Аккаунты", "accounts"),
|
||||
("routing", "Маршрутизация", "routing"),
|
||||
("providers", "Провайдеры", "providers"),
|
||||
("quotas", "Квоты и лимиты", "quotas"),
|
||||
("providers", "Модели и провайдеры", "providers"),
|
||||
("quotas", "Сводка лимитов", "quotas"),
|
||||
("analytics", "Аналитика", "analytics"),
|
||||
("health", "Состояние", "health"),
|
||||
("logs", "Журнал событий", "logs"),
|
||||
|
|
@ -595,28 +607,33 @@ class HermesHubApp(ctk.CTk):
|
|||
if action == "set_main":
|
||||
self._run_in_thread(
|
||||
lambda: do_set_main(prov, pid),
|
||||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||||
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
|
||||
)
|
||||
elif action == "set_orchestrator":
|
||||
self._run_in_thread(
|
||||
lambda: do_set_orchestrator(pid),
|
||||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||||
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
|
||||
)
|
||||
elif action == "test":
|
||||
self._show_toast(f"⚡ Тестирование {data.get('display_name', pid)}...")
|
||||
self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}…", None)
|
||||
self._run_in_thread(
|
||||
lambda: do_test_profile(prov, pid),
|
||||
on_success=self._show_test_result,
|
||||
on_success=lambda result: self._show_test_result(result, pid),
|
||||
)
|
||||
elif action == "oauth" or action == "add_account":
|
||||
self._open_add_account_wizard()
|
||||
elif action == "delete_credentials":
|
||||
self._run_in_thread(
|
||||
lambda: do_delete_credentials(prov, pid),
|
||||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||||
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
|
||||
)
|
||||
elif action == "assign_role":
|
||||
self._open_assign_role_modal(pid, data.get("display_name", pid))
|
||||
elif action == "agent_settings":
|
||||
self._open_agent_settings_modal(
|
||||
data.get("role_id", ""),
|
||||
pid,
|
||||
)
|
||||
elif action == "auto_assign_all":
|
||||
self._show_toast("⚡ Автоматическое распределение ролей...")
|
||||
self._run_in_thread(
|
||||
|
|
@ -639,11 +656,7 @@ class HermesHubApp(ctk.CTk):
|
|||
)
|
||||
elif action == "edit_route":
|
||||
role_id = data.get("role_id", "")
|
||||
self._show_view("team")
|
||||
team = self._views.get("team")
|
||||
if team and hasattr(team, "focus_role"):
|
||||
team.focus_role(role_id)
|
||||
self._show_toast(f"Настройка цепочки роли: {role_id}")
|
||||
self._open_route_editor_modal(role_id)
|
||||
elif action == "open_routing":
|
||||
self._show_view("routing")
|
||||
routing = self._views.get("routing")
|
||||
|
|
@ -724,6 +737,18 @@ class HermesHubApp(ctk.CTk):
|
|||
hover_color=Theme.ACCENT_HOVER,
|
||||
).pack(anchor="w", padx=8, pady=(12, 3))
|
||||
|
||||
result_label = ctk.CTkLabel(
|
||||
modal.body,
|
||||
text="",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
wraplength=440,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
)
|
||||
result_label.pack(fill="x", padx=8, pady=(8, 0))
|
||||
modal.result_label = result_label
|
||||
|
||||
def _save():
|
||||
chosen = role_var.get()
|
||||
ok, msg = AutoAssigner.assign_profile_to_role(
|
||||
|
|
@ -731,12 +756,279 @@ class HermesHubApp(ctk.CTk):
|
|||
chosen,
|
||||
is_primary=primary_var.get() and chosen != "spare",
|
||||
)
|
||||
modal.destroy()
|
||||
self._show_toast(f"✅ {msg}" if ok else f"❌ {msg}")
|
||||
self._refresh_data()
|
||||
result_label.configure(
|
||||
text=f"{'✓' if ok else '✕'} {msg}",
|
||||
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
|
||||
)
|
||||
self._show_account_action_result(profile_id, msg, ok)
|
||||
if ok:
|
||||
save_button.configure(text="Готово", command=modal.destroy)
|
||||
self._refresh_data()
|
||||
|
||||
HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left")
|
||||
HubButton(modal.footer, text="Применить роль", variant="primary", width=160, command=_save).pack(side="right")
|
||||
save_button = HubButton(
|
||||
modal.footer, text="Применить роль", variant="primary", width=160, command=_save
|
||||
)
|
||||
save_button.pack(side="right")
|
||||
modal.save_button = save_button
|
||||
return modal
|
||||
|
||||
def _open_route_editor_modal(self, role_id: str):
|
||||
"""Direct, visible editor for one ordered failover chain."""
|
||||
config = load_router_config()
|
||||
policy = config.roles.get(role_id)
|
||||
if policy is None:
|
||||
self._show_toast(f"❌ Роль '{role_id}' не найдена")
|
||||
return None
|
||||
modal = HubModal(self, title=f"Цепочка маршрутизации: {role_id}", width=620, height=520)
|
||||
ctk.CTkLabel(
|
||||
modal.body,
|
||||
text="Первый профиль — основной. Ниже идут резервы в порядке переключения.",
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(anchor="w", pady=(0, 10))
|
||||
chain = list(policy.preferred_chain)
|
||||
rows = ctk.CTkFrame(modal.body, fg_color="transparent")
|
||||
rows.pack(fill="both", expand=True)
|
||||
result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
result.pack(fill="x", pady=(6, 0))
|
||||
|
||||
def _render() -> None:
|
||||
for child in rows.winfo_children():
|
||||
child.destroy()
|
||||
for index, pid in enumerate(chain):
|
||||
pcfg = load_router_config().profiles.get(pid)
|
||||
row = ctk.CTkFrame(rows, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
row.pack(fill="x", pady=3)
|
||||
ctk.CTkLabel(
|
||||
row,
|
||||
text=f"{index + 1}. {pid}",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(side="left", padx=10, pady=8)
|
||||
ctk.CTkLabel(
|
||||
row,
|
||||
text=(pcfg.provider if pcfg else "профиль не найден"),
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
).pack(side="left", padx=6)
|
||||
|
||||
def _move(delta: int, current: int = index) -> None:
|
||||
target = current + delta
|
||||
if 0 <= target < len(chain):
|
||||
chain[current], chain[target] = chain[target], chain[current]
|
||||
_render()
|
||||
|
||||
def _remove(current: int = index) -> None:
|
||||
chain.pop(current)
|
||||
_render()
|
||||
|
||||
HubButton(row, text="Удалить", variant="ghost", width=70, command=_remove).pack(
|
||||
side="right", padx=(2, 8), pady=5
|
||||
)
|
||||
HubButton(row, text="↓", variant="secondary", width=34, command=lambda i=index: _move(1, i)).pack(
|
||||
side="right", padx=2, pady=5
|
||||
)
|
||||
HubButton(row, text="↑", variant="secondary", width=34, command=lambda i=index: _move(-1, i)).pack(
|
||||
side="right", padx=2, pady=5
|
||||
)
|
||||
|
||||
add_row = ctk.CTkFrame(modal.body, fg_color="transparent")
|
||||
add_row.pack(fill="x", pady=(8, 0))
|
||||
# Keep every profile in the selector so a just-removed item can be
|
||||
# re-added immediately without closing and reopening the editor.
|
||||
available = list(config.profiles)
|
||||
add_var = ctk.StringVar(value=available[0] if available else "Нет доступных профилей")
|
||||
add_menu = ctk.CTkOptionMenu(
|
||||
add_row,
|
||||
values=available or ["Нет доступных профилей"],
|
||||
variable=add_var,
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.ACCENT,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
add_menu.pack(side="left", fill="x", expand=True)
|
||||
|
||||
def _add() -> None:
|
||||
pid = add_var.get()
|
||||
if pid in config.profiles and pid not in chain:
|
||||
chain.append(pid)
|
||||
_render()
|
||||
|
||||
HubButton(add_row, text="+ Добавить в цепочку", variant="secondary", command=_add).pack(side="right", padx=(8, 0))
|
||||
|
||||
def _save_chain() -> None:
|
||||
ok, message = persist_role_chain(role_id, chain)
|
||||
result.configure(
|
||||
text=f"{'✓' if ok else '✕'} {message}",
|
||||
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
|
||||
)
|
||||
if ok:
|
||||
save_button.configure(text="Готово", command=modal.destroy)
|
||||
self._refresh_data()
|
||||
|
||||
_render()
|
||||
HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left")
|
||||
save_button = HubButton(modal.footer, text="Сохранить цепочку", variant="primary", command=_save_chain)
|
||||
save_button.pack(side="right")
|
||||
return modal
|
||||
|
||||
def _open_agent_settings_modal(self, role_id: str, profile_id: str):
|
||||
"""Open practical role settings from a click anywhere on an agent card."""
|
||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||
|
||||
config = load_router_config()
|
||||
role = config.roles.get(role_id)
|
||||
role_labels = {
|
||||
"orchestrator": "Главный оркестратор",
|
||||
"coder-primary": "Кодер 1",
|
||||
"coder-secondary": "Кодер 2",
|
||||
"reviewer": "Ревьюер",
|
||||
"research": "Исследователь",
|
||||
"fast": "Быстрый агент",
|
||||
}
|
||||
modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=560, height=540)
|
||||
ctk.CTkLabel(
|
||||
modal.body,
|
||||
text="Аккаунт, модель и реальные лимиты агента",
|
||||
font=Theme.font_heading(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w", pady=(0, 12))
|
||||
|
||||
choices: dict[str, str] = {}
|
||||
for pid, pcfg in config.profiles.items():
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
if not status.get("authenticated"):
|
||||
continue
|
||||
identity = AccountQuotaService.get().get_identity(pcfg.provider, pid).primary_identifier()
|
||||
label = f"{pid} • {identity}"
|
||||
choices[label] = pid
|
||||
if not choices:
|
||||
ctk.CTkLabel(
|
||||
modal.body,
|
||||
text="Нет подключённых аккаунтов. Сначала добавьте аккаунт.",
|
||||
text_color=Theme.STATUS_WARNING,
|
||||
).pack(anchor="w")
|
||||
HubButton(modal.footer, text="Закрыть", variant="secondary", command=modal.destroy).pack(side="right")
|
||||
return modal
|
||||
|
||||
selected_label = next((label for label, pid in choices.items() if pid == profile_id), next(iter(choices)))
|
||||
account_var = ctk.StringVar(value=selected_label)
|
||||
model_var = ctk.StringVar(value="")
|
||||
|
||||
ctk.CTkLabel(modal.body, text="Аккаунт", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||||
anchor="w"
|
||||
)
|
||||
account_menu = ctk.CTkOptionMenu(
|
||||
modal.body,
|
||||
values=list(choices),
|
||||
variable=account_var,
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.ACCENT,
|
||||
button_hover_color=Theme.ACCENT_HOVER,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
account_menu.pack(fill="x", pady=(3, 10))
|
||||
|
||||
ctk.CTkLabel(modal.body, text="Модель агента", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||||
anchor="w"
|
||||
)
|
||||
model_menu = ctk.CTkOptionMenu(
|
||||
modal.body,
|
||||
values=["default"],
|
||||
variable=model_var,
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.ACCENT,
|
||||
button_hover_color=Theme.ACCENT_HOVER,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
model_menu.pack(fill="x", pady=(3, 10))
|
||||
|
||||
quota_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
|
||||
quota_card.pack(fill="both", expand=True, pady=(2, 10))
|
||||
quota_title = ctk.CTkLabel(
|
||||
quota_card, text="Реальные лимиты", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
)
|
||||
quota_title.pack(anchor="w", padx=12, pady=(10, 5))
|
||||
quota_rows = ctk.CTkFrame(quota_card, fg_color="transparent")
|
||||
quota_rows.pack(fill="both", expand=True, padx=12, pady=(0, 8))
|
||||
|
||||
provider_models = {
|
||||
"antigravity": [
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.6-flash-high",
|
||||
"gemini-3.5-flash",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-6-thinking",
|
||||
],
|
||||
"openai-codex": ["gpt-4o", "o3-mini", "codex"],
|
||||
"opencode-go": ["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"],
|
||||
"claude": ["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"],
|
||||
"grok": ["grok-3", "grok-3-mini", "grok-2"],
|
||||
}
|
||||
|
||||
def _refresh_account_panel(_choice: Optional[str] = None) -> None:
|
||||
current_pid = choices[account_var.get()]
|
||||
current_cfg = load_router_config().profiles[current_pid]
|
||||
available = list(dict.fromkeys(current_cfg.preferred_models + provider_models.get(current_cfg.provider, [])))
|
||||
if not available:
|
||||
available = ["default"]
|
||||
model_menu.configure(values=available)
|
||||
current_model = role.default_model if role and role.default_model in available else available[0]
|
||||
model_var.set(current_model)
|
||||
for child in quota_rows.winfo_children():
|
||||
child.destroy()
|
||||
snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid)
|
||||
buckets = list(snapshot.buckets) if snapshot else []
|
||||
quota_title.configure(text=f"Реальные лимиты • {current_cfg.provider}")
|
||||
if not buckets:
|
||||
ctk.CTkLabel(
|
||||
quota_rows, text="Лимиты пока не получены", text_color=Theme.TEXT_MUTED, font=Theme.font_caption()
|
||||
).pack(anchor="w", pady=5)
|
||||
for bucket in buckets[:6]:
|
||||
row = ctk.CTkFrame(quota_rows, fg_color="transparent")
|
||||
row.pack(fill="x", pady=3)
|
||||
ctk.CTkLabel(
|
||||
row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(side="left")
|
||||
reset = bucket.formatted_reset() or "Сброс: Н/Д"
|
||||
ctk.CTkLabel(
|
||||
row,
|
||||
text=f"{bucket.formatted_remaining()} • {reset}",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.STATUS_HEALTHY if bucket.status == "healthy" else Theme.STATUS_WARNING,
|
||||
).pack(side="right")
|
||||
|
||||
account_menu.configure(command=_refresh_account_panel)
|
||||
_refresh_account_panel()
|
||||
result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
result.pack(fill="x")
|
||||
|
||||
def _save_agent() -> None:
|
||||
current_pid = choices[account_var.get()]
|
||||
current_model = model_var.get()
|
||||
updated = load_router_config()
|
||||
profile = updated.profiles[current_pid]
|
||||
profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model]
|
||||
updated.profiles[current_pid] = profile
|
||||
if role_id in updated.roles:
|
||||
updated.roles[role_id].default_model = current_model
|
||||
if not save_router_config(updated):
|
||||
result.configure(text="✕ Не удалось сохранить модель", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True)
|
||||
result.configure(
|
||||
text=f"{'✓' if ok else '✕'} {message}",
|
||||
text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR,
|
||||
)
|
||||
if ok:
|
||||
save_button.configure(text="Готово", command=modal.destroy)
|
||||
self._refresh_data()
|
||||
|
||||
HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left")
|
||||
save_button = HubButton(modal.footer, text="Сохранить настройки", variant="primary", command=_save_agent)
|
||||
save_button.pack(side="right")
|
||||
return modal
|
||||
|
||||
def _open_add_account_wizard(self):
|
||||
wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete)
|
||||
|
|
@ -745,12 +1037,19 @@ class HermesHubApp(ctk.CTk):
|
|||
self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён")
|
||||
self._refresh_data()
|
||||
|
||||
def _show_test_result(self, result: Dict[str, Any]):
|
||||
def _show_account_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> None:
|
||||
view = self._views.get("accounts")
|
||||
if view and hasattr(view, "show_action_result"):
|
||||
view.show_action_result(profile_id, message, success)
|
||||
prefix = "✅" if success is True else "❌" if success is False else "⚡"
|
||||
self._show_toast(f"{prefix} {message}")
|
||||
|
||||
def _show_test_result(self, result: Dict[str, Any], profile_id: str = ""):
|
||||
if result.get("success"):
|
||||
msg = f"✓ Профиль готов | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s"
|
||||
msg = f"Профиль готов • модель: {result.get('model')} • время: {result.get('duration_sec')} с"
|
||||
else:
|
||||
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
|
||||
self._show_toast(msg)
|
||||
msg = f"Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
|
||||
self._show_account_action_result(profile_id, msg, bool(result.get("success")))
|
||||
|
||||
def _apply_theme(self, scheme: str) -> None:
|
||||
"""Apply all palette tokens by rebuilding presentation widgets in place."""
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import logging
|
|||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
|
@ -164,17 +166,14 @@ class AccountQuotaService:
|
|||
def fetch_all_configured(self, force: bool = False) -> Dict[str, QuotaSnapshot]:
|
||||
"""Fetch quota for all configured profiles across all providers."""
|
||||
results: Dict[str, QuotaSnapshot] = {}
|
||||
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
|
||||
for prov in providers:
|
||||
# Check slots
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
slots = AutoAssigner.PRESET_SLOTS.get(prov, [])
|
||||
for slot_id in slots:
|
||||
auth = ProfileAuthManager.load_profile_auth(prov, slot_id)
|
||||
if auth:
|
||||
snap = self.fetch_account_quota(prov, slot_id, force=force)
|
||||
results[f"{prov}:{slot_id}"] = snap
|
||||
for profile_id, profile in load_router_config().profiles.items():
|
||||
auth = ProfileAuthManager.load_profile_auth(profile.provider, profile_id)
|
||||
if not auth:
|
||||
continue
|
||||
snap = self.fetch_account_quota(profile.provider, profile_id, force=force)
|
||||
results[f"{profile.provider}:{profile_id}"] = snap
|
||||
return results
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -313,60 +312,99 @@ class AccountQuotaService:
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _collect_antigravity_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
"""Collect separate Claude (5h, Weekly) and Gemini (5h, Weekly) quota pools for Google Antigravity."""
|
||||
now = _utc_now()
|
||||
claude_reset_5h = now + timedelta(hours=5)
|
||||
gemini_reset_5h = now + timedelta(hours=5)
|
||||
weekly_reset = now + timedelta(days=7)
|
||||
"""Read measured per-model capacity from the official Cloud Code endpoint.
|
||||
|
||||
# Build separate capacity buckets
|
||||
b_claude_5h = QuotaBucket(
|
||||
id="antigravity.claude.5h",
|
||||
display_name="Claude 5h",
|
||||
model_family="claude",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="5h",
|
||||
reset_at=claude_reset_5h,
|
||||
status="unknown",
|
||||
)
|
||||
b_claude_weekly = QuotaBucket(
|
||||
id="antigravity.claude.weekly",
|
||||
display_name="Claude Weekly",
|
||||
model_family="claude",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=weekly_reset,
|
||||
status="unknown",
|
||||
)
|
||||
b_gemini_5h = QuotaBucket(
|
||||
id="antigravity.gemini.5h",
|
||||
display_name="Gemini 5h",
|
||||
model_family="gemini",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="5h",
|
||||
reset_at=gemini_reset_5h,
|
||||
status="unknown",
|
||||
)
|
||||
b_gemini_weekly = QuotaBucket(
|
||||
id="antigravity.gemini.weekly",
|
||||
display_name="Gemini Weekly",
|
||||
model_family="gemini",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=weekly_reset,
|
||||
status="unknown",
|
||||
)
|
||||
The endpoint exposes one live capacity pool per model, not artificial
|
||||
``5h``/``weekly`` pairs. We therefore show the minimum remaining value
|
||||
in each model family. This keeps the compact card useful while never
|
||||
presenting an invented period or percentage.
|
||||
"""
|
||||
from antigravity_provider.cloudcode import antigravity_user_agent, load_or_onboard_project
|
||||
from antigravity_provider.oauth import refresh_access_token
|
||||
|
||||
now = _utc_now()
|
||||
token_data = auth_data.get("token") or auth_data.get("tokens") or auth_data
|
||||
if not isinstance(token_data, dict):
|
||||
raise RuntimeError("В профиле Antigravity отсутствует структура OAuth-токена")
|
||||
access_token = token_data.get("access_token") or token_data.get("access")
|
||||
if not access_token:
|
||||
raise RuntimeError("В профиле Antigravity отсутствует access token")
|
||||
|
||||
project_id = auth_data.get("project_id") or auth_data.get("projectId")
|
||||
if not project_id:
|
||||
project_id = load_or_onboard_project(str(access_token))
|
||||
|
||||
def _fetch(token: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
"https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
|
||||
data=json.dumps({"project": project_id}).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": antigravity_user_agent(),
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8") or "{}")
|
||||
|
||||
try:
|
||||
payload = _fetch(str(access_token))
|
||||
except urllib.error.HTTPError as exc:
|
||||
refresh_token = token_data.get("refresh_token") or token_data.get("refresh")
|
||||
if exc.code != 401 or not refresh_token:
|
||||
raise
|
||||
refreshed = refresh_access_token(str(refresh_token))
|
||||
token_data.update(refreshed)
|
||||
auth_data["token"] = token_data
|
||||
auth_data["project_id"] = project_id
|
||||
ProfileAuthManager.save_profile_auth("antigravity", profile_id, auth_data)
|
||||
payload = _fetch(str(refreshed["access_token"]))
|
||||
|
||||
models = payload.get("models") or {}
|
||||
if not isinstance(models, dict):
|
||||
raise RuntimeError("Cloud Code вернул некорректный список моделей")
|
||||
|
||||
family_values: dict[str, list[tuple[float, Optional[datetime]]]] = {"claude": [], "gemini": []}
|
||||
for model_id, model_data in models.items():
|
||||
if not isinstance(model_data, dict):
|
||||
continue
|
||||
lowered = str(model_id).lower()
|
||||
family = "claude" if "claude" in lowered else ("gemini" if "gemini" in lowered else None)
|
||||
quota_info = model_data.get("quotaInfo") or {}
|
||||
remaining_fraction = quota_info.get("remainingFraction") if isinstance(quota_info, dict) else None
|
||||
if family is None or not isinstance(remaining_fraction, (int, float)):
|
||||
continue
|
||||
reset_at = _parse_datetime(quota_info.get("resetTime"))
|
||||
family_values[family].append((max(0.0, min(100.0, float(remaining_fraction) * 100.0)), reset_at))
|
||||
|
||||
buckets: list[QuotaBucket] = []
|
||||
for family, display_name in (("claude", "Claude • модели"), ("gemini", "Gemini • модели")):
|
||||
values = family_values[family]
|
||||
if not values:
|
||||
continue
|
||||
remaining, reset_at = min(values, key=lambda item: item[0])
|
||||
buckets.append(
|
||||
QuotaBucket(
|
||||
id=f"antigravity.{family}.model_pool",
|
||||
display_name=display_name,
|
||||
model_family=family,
|
||||
remaining_percent=remaining,
|
||||
reset_at=reset_at,
|
||||
period="provider",
|
||||
unit="model capacity",
|
||||
scope="model_family",
|
||||
)
|
||||
)
|
||||
if not buckets:
|
||||
raise RuntimeError("Cloud Code не вернул измеряемые квоты Claude или Gemini")
|
||||
|
||||
return QuotaSnapshot(
|
||||
account_id=profile_id,
|
||||
provider="antigravity",
|
||||
buckets=[b_claude_5h, b_claude_weekly, b_gemini_5h, b_gemini_weekly],
|
||||
buckets=buckets,
|
||||
fetched_at=now,
|
||||
source="baseline",
|
||||
source="provider_api",
|
||||
)
|
||||
|
||||
def _collect_codex_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
|
|
|
|||
|
|
@ -212,9 +212,10 @@ class AddAccountWizard(HubModal):
|
|||
self._clear_body()
|
||||
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
|
||||
|
||||
self.target_slot = (
|
||||
AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
|
||||
)
|
||||
self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or ""
|
||||
if not self.target_slot:
|
||||
self._show_no_free_slot()
|
||||
return
|
||||
|
||||
if self.selected_provider == "antigravity":
|
||||
self._build_antigravity_oauth_flow()
|
||||
|
|
@ -236,6 +237,36 @@ class AddAccountWizard(HubModal):
|
|||
else:
|
||||
self._build_api_key_flow()
|
||||
|
||||
def _show_no_free_slot(self) -> None:
|
||||
"""Stop before OAuth when the configured provider has no real free slot."""
|
||||
provider_labels = {
|
||||
"antigravity": "Google Antigravity",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"claude": "Claude",
|
||||
"grok": "Grok",
|
||||
}
|
||||
card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED)
|
||||
card.pack(fill="x", pady=20)
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text="Нет свободного слота",
|
||||
font=Theme.font_heading(),
|
||||
text_color=Theme.STATUS_WARNING,
|
||||
).pack(anchor="w", padx=16, pady=(14, 6))
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text=(
|
||||
f"Все слоты провайдера {provider_labels.get(self.selected_provider, self.selected_provider)} заняты.\n"
|
||||
"Освободите один слот или удалите неиспользуемый аккаунт, затем повторите подключение."
|
||||
),
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
justify="left",
|
||||
wraplength=520,
|
||||
).pack(anchor="w", padx=16, pady=(0, 14))
|
||||
self._build_step_2_footer()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GOOGLE ANTIGRAVITY OAUTH FLOW
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -484,22 +515,10 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
ctk.CTkLabel(
|
||||
auth_card,
|
||||
text=(
|
||||
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице OpenAI. "
|
||||
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется."
|
||||
),
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
wraplength=520,
|
||||
justify="left",
|
||||
).pack(anchor="w", padx=10, pady=(7, 2))
|
||||
|
||||
ctk.CTkLabel(
|
||||
auth_card,
|
||||
text="Ссылка для входа в OpenAI (ChatGPT):",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(anchor="w", padx=10, pady=(6, 2))
|
||||
text="1. Откройте ссылку — кнопка ниже откроет страницу OpenAI",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w", padx=10, pady=(8, 2))
|
||||
|
||||
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||||
url_row.pack(fill="x", padx=10, pady=(0, 6))
|
||||
|
|
@ -516,9 +535,9 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
HubButton(
|
||||
url_row,
|
||||
text="📋",
|
||||
text="Копировать ссылку",
|
||||
variant="secondary",
|
||||
width=40,
|
||||
width=130,
|
||||
height=32,
|
||||
command=self._copy_codex_url,
|
||||
).pack(side="right")
|
||||
|
|
@ -528,7 +547,7 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
ctk.CTkLabel(
|
||||
code_card,
|
||||
text="Код подтверждения:",
|
||||
text="2. Введите на странице код:",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
|
|
@ -536,7 +555,7 @@ class AddAccountWizard(HubModal):
|
|||
self.codex_code_lbl = ctk.CTkLabel(
|
||||
code_card,
|
||||
text="...",
|
||||
font=Theme.font_mono_bold(),
|
||||
font=("Consolas", 18, "bold"),
|
||||
text_color=Theme.ACCENT,
|
||||
)
|
||||
self.codex_code_lbl.pack(side="left", padx=(0, 8))
|
||||
|
|
@ -561,6 +580,13 @@ class AddAccountWizard(HubModal):
|
|||
command=self._open_codex_browser,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
|
||||
ctk.CTkLabel(
|
||||
auth_card,
|
||||
text="3. Подтвердите доступ — мастер продолжит автоматически",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w", padx=10, pady=(0, 8))
|
||||
|
||||
# Manual Fallback Card
|
||||
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||||
manual_card.pack(fill="x", pady=(0, 8))
|
||||
|
|
@ -779,9 +805,9 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
HubButton(
|
||||
url_row,
|
||||
text="📋",
|
||||
text="Копировать ссылку",
|
||||
variant="secondary",
|
||||
width=40,
|
||||
width=130,
|
||||
height=32,
|
||||
command=self._copy_claude_url,
|
||||
).pack(side="right")
|
||||
|
|
@ -940,22 +966,10 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
ctk.CTkLabel(
|
||||
auth_card,
|
||||
text=(
|
||||
"1. Нажмите «Открыть в браузере». 2. Вставьте показанный ниже код на странице xAI. "
|
||||
"3. Вернитесь в Hub — поле для этого кода в приложении не требуется."
|
||||
),
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
wraplength=520,
|
||||
justify="left",
|
||||
).pack(anchor="w", padx=10, pady=(7, 2))
|
||||
|
||||
ctk.CTkLabel(
|
||||
auth_card,
|
||||
text="Ссылка для входа в xAI Grok:",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(anchor="w", padx=10, pady=(6, 2))
|
||||
text="1. Откройте ссылку — кнопка ниже откроет страницу xAI",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w", padx=10, pady=(8, 2))
|
||||
|
||||
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
|
||||
url_row.pack(fill="x", padx=10, pady=(0, 6))
|
||||
|
|
@ -984,7 +998,7 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
ctk.CTkLabel(
|
||||
code_card,
|
||||
text="Код подтверждения:",
|
||||
text="2. Введите на странице код:",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
|
|
@ -992,7 +1006,7 @@ class AddAccountWizard(HubModal):
|
|||
self.grok_code_lbl = ctk.CTkLabel(
|
||||
code_card,
|
||||
text="...",
|
||||
font=Theme.font_mono_bold(),
|
||||
font=("Consolas", 18, "bold"),
|
||||
text_color=Theme.PROVIDER_GROK,
|
||||
)
|
||||
self.grok_code_lbl.pack(side="left", padx=(0, 8))
|
||||
|
|
@ -1017,6 +1031,13 @@ class AddAccountWizard(HubModal):
|
|||
command=self._open_grok_browser,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
|
||||
ctk.CTkLabel(
|
||||
auth_card,
|
||||
text="3. Подтвердите доступ — мастер продолжит автоматически",
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w", padx=10, pady=(0, 8))
|
||||
|
||||
# Manual Entry Card
|
||||
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
|
||||
manual_card.pack(fill="x", pady=(0, 8))
|
||||
|
|
@ -1277,13 +1298,14 @@ class AddAccountWizard(HubModal):
|
|||
entry_row,
|
||||
placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."),
|
||||
font=Theme.font_mono(),
|
||||
show="*",
|
||||
show="" if self.selected_provider == "opencode-go" else "*",
|
||||
height=38,
|
||||
fg_color=Theme.PRIMARY,
|
||||
border_color=Theme.BORDER,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||
self.key_entry.focus_set()
|
||||
|
||||
HubButton(
|
||||
entry_row,
|
||||
|
|
@ -1359,12 +1381,19 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
def _paste_into_entry(self, entry_widget: HubEntry):
|
||||
try:
|
||||
content = self.clipboard_get().strip()
|
||||
content = entry_widget.clipboard_get().strip()
|
||||
if content:
|
||||
entry_widget.delete(0, "end")
|
||||
entry_widget.insert(0, content)
|
||||
except Exception:
|
||||
pass
|
||||
entry_widget.focus_set()
|
||||
entry_widget.icursor("end")
|
||||
if hasattr(self, "key_status_lbl") and entry_widget is getattr(self, "key_entry", None):
|
||||
self.key_status_lbl.configure(text="✓ Ключ вставлен. Нажмите «Проверить и продолжить».")
|
||||
elif hasattr(self, "key_status_lbl"):
|
||||
self.key_status_lbl.configure(text="Буфер обмена пуст.")
|
||||
except Exception as exc:
|
||||
if hasattr(self, "key_status_lbl"):
|
||||
self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# STEP 3: Validation & Identity
|
||||
|
|
@ -1512,6 +1541,19 @@ class AddAccountWizard(HubModal):
|
|||
).pack(side="right", padx=10, pady=10)
|
||||
|
||||
def _finish(self):
|
||||
if not self.target_slot:
|
||||
self.finish_status_lbl.configure(
|
||||
text="❌ Подключение невозможно: свободный слот не найден.",
|
||||
text_color=Theme.STATUS_ERROR,
|
||||
)
|
||||
return
|
||||
profile_ok, profile_message = AutoAssigner.ensure_profile_definition(
|
||||
self.selected_provider,
|
||||
self.target_slot,
|
||||
)
|
||||
if not profile_ok:
|
||||
self.finish_status_lbl.configure(text=f"❌ {profile_message}", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
# Most built-in slots already occur in a default chain. Custom or
|
||||
# repaired configs may not, so completion makes that invariant explicit
|
||||
# without reordering a slot that is already assigned.
|
||||
|
|
|
|||
|
|
@ -888,6 +888,31 @@ class AccountCardWidget(HubCard):
|
|||
self.status = StatusBadge(self, status)
|
||||
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||
|
||||
self.compact_quota = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.compact_quota_labels = [
|
||||
ctk.CTkLabel(
|
||||
self.compact_quota,
|
||||
text="",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
anchor="w",
|
||||
)
|
||||
for _ in range(4)
|
||||
]
|
||||
for index, label in enumerate(self.compact_quota_labels):
|
||||
label.grid(row=index // 2, column=index % 2, sticky="ew", padx=(0, 8), pady=2)
|
||||
self.compact_quota.grid_columnconfigure(index % 2, weight=1)
|
||||
self.compact_actions = ctk.CTkFrame(self, fg_color="transparent")
|
||||
for text, action in (("⚡", "test"), ("↻", "refresh_account"), ("Роль", "assign_role")):
|
||||
ActionButton(
|
||||
self.compact_actions,
|
||||
text=text,
|
||||
variant="ghost",
|
||||
width=48,
|
||||
height=Theme.HEIGHT_BTN_SM,
|
||||
command=lambda action_name=action: self._trigger(action_name),
|
||||
).pack(side="left", padx=(0, Theme.SPACE_XS))
|
||||
|
||||
self.details = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.details.pack(fill="x")
|
||||
self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent")
|
||||
|
|
@ -897,6 +922,15 @@ class AccountCardWidget(HubCard):
|
|||
)
|
||||
self.actions = ctk.CTkFrame(self.details, fg_color="transparent")
|
||||
self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
|
||||
self.action_feedback = ctk.CTkLabel(
|
||||
self.actions,
|
||||
text="",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
wraplength=330,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
)
|
||||
self.action_buttons: Dict[str, ActionButton] = {}
|
||||
management = ctk.CTkFrame(self.actions, fg_color="transparent")
|
||||
management.pack(fill="x", pady=(0, Theme.SPACE_XS))
|
||||
|
|
@ -944,8 +978,19 @@ class AccountCardWidget(HubCard):
|
|||
|
||||
def _trigger(self, action: str) -> None:
|
||||
if self.on_action and self.profile_model is not None:
|
||||
self.set_action_feedback("Выполняется…", None)
|
||||
self.on_action(action, self.profile_model)
|
||||
|
||||
def set_action_feedback(self, message: str, success: Optional[bool]) -> None:
|
||||
"""Show an action result where the user initiated it."""
|
||||
color = Theme.TEXT_SECONDARY
|
||||
if success is True:
|
||||
color = Theme.STATUS_HEALTHY
|
||||
elif success is False:
|
||||
color = Theme.STATUS_ERROR
|
||||
self.action_feedback.configure(text=message, text_color=color)
|
||||
self.action_feedback.pack(fill="x", pady=(0, Theme.SPACE_XS), before=self.actions.winfo_children()[1])
|
||||
|
||||
def toggle_compact(self) -> None:
|
||||
self.set_compact(not self.compact)
|
||||
|
||||
|
|
@ -954,7 +999,11 @@ class AccountCardWidget(HubCard):
|
|||
self.toggle.configure(text="▸" if compact else "▾")
|
||||
if compact:
|
||||
self.details.pack_forget()
|
||||
self.compact_quota.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_XS))
|
||||
self.compact_actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||
else:
|
||||
self.compact_quota.pack_forget()
|
||||
self.compact_actions.pack_forget()
|
||||
self.details.pack(fill="x")
|
||||
|
||||
def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None:
|
||||
|
|
@ -981,6 +1030,22 @@ class AccountCardWidget(HubCard):
|
|||
snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None)
|
||||
buckets = list(getattr(snapshot, "buckets", None) or [])
|
||||
estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True
|
||||
for index, label in enumerate(self.compact_quota_labels):
|
||||
if index < len(buckets):
|
||||
bucket = buckets[index]
|
||||
label.configure(
|
||||
text=f"{bucket.display_name}: {bucket.formatted_remaining()}",
|
||||
text_color=(
|
||||
Theme.STATUS_HEALTHY
|
||||
if getattr(bucket, "status", "unknown") == "healthy"
|
||||
else Theme.STATUS_WARNING
|
||||
if getattr(bucket, "status", "unknown") == "warning"
|
||||
else Theme.TEXT_MUTED
|
||||
),
|
||||
)
|
||||
label.grid()
|
||||
else:
|
||||
label.grid_remove()
|
||||
seen: set[str] = set()
|
||||
for bucket in buckets:
|
||||
key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket"))
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ class AccountsView(ctk.CTkFrame):
|
|||
return
|
||||
self._snapshot = snapshot
|
||||
live_ids = {profile.profile_id for profile in self._profiles()}
|
||||
collapse_new_collection = len(self._cards) <= 1 and len(live_ids) > 1
|
||||
for profile_id in list(self._cards):
|
||||
if profile_id not in live_ids:
|
||||
self._cards.pop(profile_id).destroy()
|
||||
|
|
@ -220,11 +221,15 @@ class AccountsView(ctk.CTkFrame):
|
|||
profile.profile_id,
|
||||
AccountCardWidget.resolve_identity(profile),
|
||||
profile.provider_display_name,
|
||||
compact=len(live_ids) > 1,
|
||||
on_action=self._emit,
|
||||
)
|
||||
self._cards[profile.profile_id] = card
|
||||
self.cards_created += 1
|
||||
card.update_account(profile, snapshot.quotas.get(profile.profile_id))
|
||||
if collapse_new_collection:
|
||||
for card in self._cards.values():
|
||||
card.set_compact(True)
|
||||
self._render_visibility()
|
||||
|
||||
def _render_visibility(self) -> None:
|
||||
|
|
@ -267,3 +272,11 @@ class AccountsView(ctk.CTkFrame):
|
|||
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
|
||||
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
|
||||
}
|
||||
|
||||
def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool:
|
||||
"""Keep account action feedback next to the originating controls."""
|
||||
card = self._cards.get(profile_id)
|
||||
if card is None:
|
||||
return False
|
||||
card.set_action_feedback(message, success)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -93,6 +93,21 @@ class _EndpointCard(HubCard):
|
|||
)
|
||||
self.progress.pack(fill="x", pady=(5, 0))
|
||||
self.progress.set(0)
|
||||
self._click_action: Optional[Callable[[], None]] = None
|
||||
self._bind_click_tree(self)
|
||||
|
||||
def _bind_click_tree(self, widget: Any) -> None:
|
||||
widget.bind("<Button-1>", self._activate, add="+")
|
||||
for child in widget.winfo_children():
|
||||
self._bind_click_tree(child)
|
||||
|
||||
def set_click_action(self, action: Optional[Callable[[], None]]) -> None:
|
||||
self._click_action = action
|
||||
self.configure(cursor="hand2" if action else "arrow")
|
||||
|
||||
def _activate(self, _event: Any = None) -> None:
|
||||
if self._click_action:
|
||||
self._click_action()
|
||||
|
||||
def update_card(
|
||||
self,
|
||||
|
|
@ -162,16 +177,16 @@ class _RouteDiagram(ctk.CTkFrame):
|
|||
"""Responsive diagram with smooth connections behind native widgets."""
|
||||
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, height=330, fg_color="transparent")
|
||||
super().__init__(master, height=440, fg_color="transparent")
|
||||
self.pack_propagate(False)
|
||||
self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
|
||||
self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1)
|
||||
self.provider_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)]
|
||||
self.agent_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)]
|
||||
self.agent_slots = [ctk.CTkFrame(self, height=68, fg_color="transparent") for _ in range(5)]
|
||||
for index, slot in enumerate(self.provider_slots):
|
||||
slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30)
|
||||
for index, slot in enumerate(self.agent_slots):
|
||||
slot.place(relx=0.71, rely=0.02 + index * 0.29, relwidth=0.278)
|
||||
slot.place(relx=0.71, rely=0.01 + index * 0.185, relwidth=0.278)
|
||||
self.orchestrator = _OrchestratorNode(self)
|
||||
self.orchestrator.place(relx=0.51, rely=0.46, anchor="center")
|
||||
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
|
||||
|
|
@ -184,12 +199,12 @@ class _RouteDiagram(ctk.CTkFrame):
|
|||
)
|
||||
self.context_status.pack()
|
||||
self._left_labels = ["", "", ""]
|
||||
self._right_labels = ["", "", ""]
|
||||
self._right_labels = ["", "", "", "", ""]
|
||||
self.bind("<Configure>", self._redraw)
|
||||
|
||||
def set_labels(self, left: list[str], right: list[str]) -> None:
|
||||
self._left_labels = (left + ["", "", ""])[:3]
|
||||
self._right_labels = (right + ["", "", ""])[:3]
|
||||
self._right_labels = (right + ["", "", "", "", ""])[:5]
|
||||
self._redraw()
|
||||
|
||||
def _redraw(self, _event: Any = None) -> None:
|
||||
|
|
@ -197,8 +212,8 @@ class _RouteDiagram(ctk.CTkFrame):
|
|||
self.canvas.delete("route")
|
||||
center_x, center_y = width * 0.51, height * 0.40
|
||||
left_x, right_x = width * 0.312, width * 0.71
|
||||
ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
|
||||
for index, y_pos in enumerate(ys):
|
||||
left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
|
||||
for index, y_pos in enumerate(left_ys):
|
||||
self.canvas.create_line(
|
||||
left_x,
|
||||
y_pos,
|
||||
|
|
@ -223,6 +238,8 @@ class _RouteDiagram(ctk.CTkFrame):
|
|||
anchor="w",
|
||||
tags="route",
|
||||
)
|
||||
right_ys = [height * (0.01 + index * 0.185) + 34 for index in range(5)]
|
||||
for index, y_pos in enumerate(right_ys):
|
||||
self.canvas.create_line(
|
||||
center_x + 48,
|
||||
center_y,
|
||||
|
|
@ -401,7 +418,7 @@ class DashboardView(ctk.CTkFrame):
|
|||
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
|
||||
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н/Д", "нет измерения")
|
||||
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н/Д", "активно: 0")
|
||||
self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли")
|
||||
self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы")
|
||||
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н/Д", "P50")
|
||||
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н/Д", "failover")
|
||||
for index, metric in enumerate(
|
||||
|
|
@ -411,10 +428,9 @@ class DashboardView(ctk.CTkFrame):
|
|||
|
||||
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
body.pack(fill="x")
|
||||
body.grid_columnconfigure(0, weight=4)
|
||||
body.grid_columnconfigure(1, weight=1)
|
||||
route_card = HubCard(body, height=370)
|
||||
route_card.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||
body.grid_columnconfigure(0, weight=1)
|
||||
route_card = HubCard(body, height=480)
|
||||
route_card.grid(row=0, column=0, sticky="nsew")
|
||||
route_card.grid_propagate(False)
|
||||
ctk.CTkLabel(
|
||||
route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
|
|
@ -422,8 +438,10 @@ class DashboardView(ctk.CTkFrame):
|
|||
self.route_diagram = _RouteDiagram(route_card)
|
||||
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
|
||||
|
||||
self.realtime = HubCard(body, height=370)
|
||||
self.realtime.grid(row=0, column=1, sticky="nsew")
|
||||
# Metrics still exist as data probes but are intentionally not shown on
|
||||
# Overview. The previous right rail duplicated Accounts/Status data
|
||||
# and made the useful routing canvas unnecessarily narrow.
|
||||
self.realtime = HubCard(self, height=1)
|
||||
self.realtime.grid_propagate(False)
|
||||
ctk.CTkLabel(
|
||||
self.realtime,
|
||||
|
|
@ -503,7 +521,7 @@ class DashboardView(ctk.CTkFrame):
|
|||
cache: Dict[str, _EndpointCard],
|
||||
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
|
||||
) -> None:
|
||||
prepared = list(items)[:3]
|
||||
prepared = list(items)[: len(slots)]
|
||||
live = {key for key, *_rest in prepared}
|
||||
for key in list(cache):
|
||||
if key not in live:
|
||||
|
|
@ -552,9 +570,9 @@ class DashboardView(ctk.CTkFrame):
|
|||
float(total_calls) if total_calls is not None else None,
|
||||
)
|
||||
self.agents_metric.set_metric(
|
||||
f"{readiness.roles_ready_count}/{readiness.total_roles}",
|
||||
"готовые роли",
|
||||
float(readiness.roles_ready_count),
|
||||
str(readiness.accounts_connected_count),
|
||||
f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}",
|
||||
float(readiness.accounts_connected_count),
|
||||
)
|
||||
self.latency_metric.set_metric(
|
||||
f"{latency:.0f} мс" if latency is not None else "Н/Д",
|
||||
|
|
@ -576,13 +594,16 @@ class DashboardView(ctk.CTkFrame):
|
|||
provider.provider_id,
|
||||
provider.provider_id,
|
||||
provider.provider_name,
|
||||
next(
|
||||
(
|
||||
profile.preferred_models[0]
|
||||
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
|
||||
if profile.preferred_models
|
||||
),
|
||||
"Модель: Н/Д",
|
||||
(
|
||||
f"{provider.connected_count} аккаунт(а) • "
|
||||
+ next(
|
||||
(
|
||||
profile.preferred_models[0]
|
||||
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
|
||||
if profile.preferred_models
|
||||
),
|
||||
"модель: Н/Д",
|
||||
)
|
||||
),
|
||||
"Онлайн" if provider.online_count else "Недоступен",
|
||||
*self._quota_measurement(snapshot, provider.provider_id),
|
||||
|
|
@ -601,7 +622,7 @@ class DashboardView(ctk.CTkFrame):
|
|||
else:
|
||||
self.route_diagram.orchestrator.update_node("Не назначен", "Н/Д", False)
|
||||
|
||||
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3]
|
||||
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5]
|
||||
self._sync_endpoint_cards(
|
||||
self.route_diagram.agent_slots,
|
||||
self._agent_cards,
|
||||
|
|
@ -619,6 +640,21 @@ class DashboardView(ctk.CTkFrame):
|
|||
for agent in agents
|
||||
),
|
||||
)
|
||||
for agent in agents:
|
||||
card = self._agent_cards.get(agent.role_id)
|
||||
if card is not None:
|
||||
card.set_click_action(
|
||||
lambda current=agent: self.on_action(
|
||||
"agent_settings",
|
||||
{
|
||||
"role_id": current.role_id,
|
||||
"profile_id": current.assigned_profile_id,
|
||||
"provider": current.provider,
|
||||
},
|
||||
)
|
||||
if self.on_action
|
||||
else None
|
||||
)
|
||||
left_labels: list[str] = []
|
||||
for provider in providers:
|
||||
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
|
||||
|
|
@ -641,11 +677,9 @@ class DashboardView(ctk.CTkFrame):
|
|||
row.pack(fill="x")
|
||||
self._provider_status_rows[provider.provider_id] = row
|
||||
provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms")
|
||||
detail = (
|
||||
f"{provider_latency:.0f} мс"
|
||||
if provider_latency is not None
|
||||
else f"{provider.online_count}/{provider.connected_count}"
|
||||
)
|
||||
detail = f"{provider.online_count}/{provider.connected_count} онлайн"
|
||||
if provider_latency is not None:
|
||||
detail += f" • {provider_latency:.0f} мс"
|
||||
row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning")
|
||||
|
||||
host = dict(snapshot.metrics.get("host") or {})
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class RoutingRoleWidget(HubCard):
|
|||
self.meta.pack(side="left", padx=Theme.SPACE_SM)
|
||||
ActionButton(
|
||||
top,
|
||||
text="Настроить",
|
||||
text="Изменить цепочку →",
|
||||
variant="secondary",
|
||||
width=90,
|
||||
command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from antigravity_provider.router.unified_health import (
|
|||
STATUS_AUTH_EXPIRED,
|
||||
)
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.event_bus import (
|
||||
EVENT_ACCOUNT_ADDED,
|
||||
EVENT_ACCOUNT_AUTH_CHANGED,
|
||||
|
|
@ -38,6 +39,37 @@ from antigravity_provider.router.router_config import load_router_config
|
|||
from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController
|
||||
|
||||
|
||||
def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]:
|
||||
"""Persist one ordered chain using AutoAssigner while preserving other roles."""
|
||||
config = load_router_config()
|
||||
policy = config.roles.get(role_id)
|
||||
if policy is None:
|
||||
return False, f"Роль '{role_id}' не найдена"
|
||||
if len(desired_chain) != len(set(desired_chain)):
|
||||
return False, "Профиль не может повторяться в одной цепочке"
|
||||
missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles]
|
||||
if missing:
|
||||
return False, f"Профиль '{missing[0]}' не найден"
|
||||
|
||||
original = {key: list(value.preferred_chain) for key, value in config.roles.items()}
|
||||
removed = set(original[role_id]) - set(desired_chain)
|
||||
affected = {role_id}
|
||||
for profile_id in removed:
|
||||
affected.update(key for key, chain in original.items() if profile_id in chain)
|
||||
ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False)
|
||||
if not ok:
|
||||
return False, message
|
||||
|
||||
chains = {key: original[key] for key in affected}
|
||||
chains[role_id] = list(desired_chain)
|
||||
for target_role, chain in chains.items():
|
||||
for profile_id in reversed(chain):
|
||||
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
|
||||
if not ok:
|
||||
return False, message
|
||||
return True, f"Цепочка '{role_id}' сохранена"
|
||||
|
||||
|
||||
class AgentCardWidget(HubCard):
|
||||
"""Reusable agent card that updates in-place without rebuilding widgets."""
|
||||
|
||||
|
|
@ -128,6 +160,27 @@ class AgentCardWidget(HubCard):
|
|||
command=self._open_menu,
|
||||
)
|
||||
self.menu_btn.pack(side="right")
|
||||
self._bind_settings_click(self)
|
||||
|
||||
def _bind_settings_click(self, widget: Any) -> None:
|
||||
if isinstance(widget, ctk.CTkButton):
|
||||
return
|
||||
widget.bind("<Button-1>", self._open_settings, add="+")
|
||||
for child in widget.winfo_children():
|
||||
self._bind_settings_click(child)
|
||||
|
||||
def _open_settings(self, _event: Any = None) -> None:
|
||||
if not self.agent_data or not self.on_action:
|
||||
return
|
||||
agent = self.agent_data
|
||||
self.on_action(
|
||||
"agent_settings",
|
||||
{
|
||||
"role_id": agent.role_id,
|
||||
"profile_id": agent.assigned_profile_id or "",
|
||||
"provider": agent.provider,
|
||||
},
|
||||
)
|
||||
|
||||
def update_agent(self, a: AgentViewModel):
|
||||
self.agent_data = a
|
||||
|
|
@ -651,11 +704,89 @@ class TeamView(ctk.CTkFrame):
|
|||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(fill="x", padx=8, pady=(2, 7))
|
||||
controls = ctk.CTkFrame(card, fg_color="transparent")
|
||||
controls.pack(fill="x", padx=6, pady=(0, 6))
|
||||
HubButton(
|
||||
controls,
|
||||
text="↑",
|
||||
variant="secondary",
|
||||
width=34,
|
||||
height=26,
|
||||
command=lambda pid=profile_id: self._move_chain_profile(pid, -1),
|
||||
).pack(side="left", padx=2)
|
||||
HubButton(
|
||||
controls,
|
||||
text="↓",
|
||||
variant="secondary",
|
||||
width=34,
|
||||
height=26,
|
||||
command=lambda pid=profile_id: self._move_chain_profile(pid, 1),
|
||||
).pack(side="left", padx=2)
|
||||
HubButton(
|
||||
controls,
|
||||
text="Удалить",
|
||||
variant="ghost",
|
||||
width=76,
|
||||
height=26,
|
||||
command=lambda pid=profile_id: self._remove_chain_profile(pid),
|
||||
).pack(side="right", padx=2)
|
||||
if not chain:
|
||||
ctk.CTkLabel(
|
||||
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
|
||||
).pack(anchor="w", padx=6, pady=8)
|
||||
|
||||
available = [profile_id for profile_id in config.profiles if profile_id not in chain]
|
||||
add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent")
|
||||
add_row.pack(fill="x", pady=(8, 2))
|
||||
add_menu = ctk.CTkOptionMenu(
|
||||
add_row,
|
||||
values=available or ["—"],
|
||||
font=Theme.font_caption(),
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.SECONDARY,
|
||||
button_hover_color=Theme.SURFACE_HOVER,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4))
|
||||
HubButton(
|
||||
add_row,
|
||||
text="Добавить",
|
||||
variant="secondary",
|
||||
width=88,
|
||||
command=lambda: self._add_chain_profile(add_menu.get()),
|
||||
state="normal" if available else "disabled",
|
||||
).pack(side="right")
|
||||
|
||||
def _persist_selected_chain(self, chain: List[str]) -> None:
|
||||
ok, message = persist_role_chain(self.selected_role, chain)
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
|
||||
if ok:
|
||||
self._update_inspector()
|
||||
|
||||
def _move_chain_profile(self, profile_id: str, direction: int) -> None:
|
||||
chain = list(self.controller.role_chain(self.selected_role))
|
||||
if profile_id not in chain:
|
||||
return
|
||||
source = chain.index(profile_id)
|
||||
target = source + direction
|
||||
if target < 0 or target >= len(chain):
|
||||
self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING)
|
||||
return
|
||||
chain[source], chain[target] = chain[target], chain[source]
|
||||
self._persist_selected_chain(chain)
|
||||
|
||||
def _remove_chain_profile(self, profile_id: str) -> None:
|
||||
chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id]
|
||||
self._persist_selected_chain(chain)
|
||||
|
||||
def _add_chain_profile(self, profile_id: str) -> None:
|
||||
if not profile_id or profile_id == "—":
|
||||
return
|
||||
chain = list(self.controller.role_chain(self.selected_role))
|
||||
if profile_id not in chain:
|
||||
chain.append(profile_id)
|
||||
self._persist_selected_chain(chain)
|
||||
|
||||
def _on_runtime_event(self, name: str, data: Any) -> None:
|
||||
# EventBus may publish from a worker. Tk mutation is always marshalled.
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -187,13 +187,30 @@ def test_quota_snapshot_model_availability():
|
|||
|
||||
def test_antigravity_separate_claude_and_gemini_buckets():
|
||||
service = AccountQuotaService()
|
||||
snap = service._collect_antigravity_quota("ag-w1", {"tokens": {}})
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value.read.return_value = json.dumps(
|
||||
{
|
||||
"models": {
|
||||
"claude-sonnet-4-6": {
|
||||
"quotaInfo": {"remainingFraction": 0.42, "resetTime": "2026-08-23T00:00:00Z"}
|
||||
},
|
||||
"gemini-3.7-flash": {
|
||||
"quotaInfo": {"remainingFraction": 0.87, "resetTime": "2026-08-23T00:00:00Z"}
|
||||
},
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
with patch("antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"), patch(
|
||||
"antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
|
||||
):
|
||||
snap = service._collect_antigravity_quota(
|
||||
"ag-w1", {"tokens": {"access_token": "access-token"}}
|
||||
)
|
||||
|
||||
bucket_ids = [b.id for b in snap.buckets]
|
||||
assert "antigravity.claude.5h" in bucket_ids
|
||||
assert "antigravity.claude.weekly" in bucket_ids
|
||||
assert "antigravity.gemini.5h" in bucket_ids
|
||||
assert "antigravity.gemini.weekly" in bucket_ids
|
||||
assert "antigravity.claude.model_pool" in bucket_ids
|
||||
assert "antigravity.gemini.model_pool" in bucket_ids
|
||||
assert snap.source == "provider_api"
|
||||
|
||||
# Claude bucket and Gemini bucket are independent
|
||||
b_c = snap.get_bucket_for_model("claude-3-7-sonnet")
|
||||
|
|
@ -201,6 +218,8 @@ def test_antigravity_separate_claude_and_gemini_buckets():
|
|||
|
||||
assert b_c is not None and b_c.model_family == "claude"
|
||||
assert b_g is not None and b_g.model_family == "gemini"
|
||||
assert b_c.remaining_percent == 42.0
|
||||
assert b_g.remaining_percent == 87.0
|
||||
|
||||
|
||||
def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
|
@ -21,6 +22,7 @@ from antigravity_provider.router.unified_health import (
|
|||
AgentViewModel,
|
||||
PipelineNode,
|
||||
ProfileViewModel,
|
||||
ProviderSummary,
|
||||
RolePipeline,
|
||||
SystemReadiness,
|
||||
)
|
||||
|
|
@ -215,6 +217,54 @@ def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
|
|||
view.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_root) -> None:
|
||||
profiles = [
|
||||
replace(
|
||||
_profile(),
|
||||
profile_id=f"ag-{index}",
|
||||
account_identity=f"user{index}@example.test",
|
||||
email=f"user{index}@example.test",
|
||||
)
|
||||
for index in range(6)
|
||||
]
|
||||
readiness = replace(_readiness(), accounts_connected_count=6, total_accounts=6)
|
||||
provider = ProviderSummary(
|
||||
provider_id="antigravity",
|
||||
provider_name="Google Antigravity",
|
||||
total_slots=10,
|
||||
connected_count=6,
|
||||
online_count=6,
|
||||
auth_required_count=0,
|
||||
quota_exhausted_count=0,
|
||||
cold_spare_count=0,
|
||||
discovered_models=["gemini-3.7-flash"],
|
||||
last_refresh_at="12:00:00",
|
||||
)
|
||||
snapshot = HubSnapshot(
|
||||
generation=1,
|
||||
seq=1,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={"antigravity": profiles},
|
||||
all_profiles={profile.profile_id: profile for profile in profiles},
|
||||
readiness=readiness,
|
||||
agents=[],
|
||||
providers=[provider],
|
||||
routing={},
|
||||
quotas={},
|
||||
)
|
||||
view = DashboardView(ui_root)
|
||||
try:
|
||||
view.pack()
|
||||
view.update_data(snapshot)
|
||||
ui_root.update_idletasks()
|
||||
assert view.agents_metric.val_label.cget("text") == "6"
|
||||
assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text")
|
||||
assert "6/6 онлайн" in view._provider_status_rows["antigravity"].detail.cget("text")
|
||||
finally:
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
|
||||
snapshot = QuotaSnapshot(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
|
@ -8,6 +9,7 @@ pytest.importorskip("customtkinter")
|
|||
|
||||
from antigravity_provider.router.ui import routing_graph as graph_module
|
||||
from antigravity_provider.router.ui import add_account_wizard as wizard_module
|
||||
from antigravity_provider.router.ui.views import team_view as team_module
|
||||
from antigravity_provider.router import hermes_hub_app as app_module
|
||||
from antigravity_provider.router.ui.routing_graph import (
|
||||
GraphEdge,
|
||||
|
|
@ -182,3 +184,139 @@ def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
|
|||
assert ("clear", "ag-orch-fallback") in calls
|
||||
assert any(item[0] == "log" for item in calls)
|
||||
assert calls[-1] == ("destroy",)
|
||||
|
||||
|
||||
def test_wizard_stops_when_provider_has_no_real_free_slot(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(wizard_module.AutoAssigner, "find_free_slot", lambda _provider: None)
|
||||
fake = SimpleNamespace(
|
||||
selected_provider="grok",
|
||||
target_slot="old-value",
|
||||
_clear_body=lambda: calls.append("clear"),
|
||||
title_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
|
||||
_show_no_free_slot=lambda: calls.append("no-slot"),
|
||||
)
|
||||
|
||||
wizard_module.AddAccountWizard._show_step_2_auth(fake)
|
||||
|
||||
assert fake.target_slot == ""
|
||||
assert calls == ["clear", "no-slot"]
|
||||
|
||||
|
||||
def test_wizard_finish_without_slot_shows_error_and_does_not_assign(monkeypatch):
|
||||
updates = []
|
||||
monkeypatch.setattr(
|
||||
wizard_module,
|
||||
"ensure_profile_in_routing",
|
||||
lambda _profile: pytest.fail("an invented or empty slot must not be assigned"),
|
||||
)
|
||||
fake = SimpleNamespace(
|
||||
target_slot="",
|
||||
finish_status_lbl=SimpleNamespace(configure=lambda **kwargs: updates.append(kwargs)),
|
||||
)
|
||||
|
||||
wizard_module.AddAccountWizard._finish(fake)
|
||||
|
||||
assert "свободный слот" in updates[-1]["text"]
|
||||
|
||||
|
||||
def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch):
|
||||
config = SimpleNamespace(
|
||||
profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
|
||||
roles={
|
||||
"orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]),
|
||||
"reviewer": SimpleNamespace(preferred_chain=["b"]),
|
||||
},
|
||||
)
|
||||
calls = []
|
||||
|
||||
def assign(profile_id, role_id, is_primary=True):
|
||||
calls.append((profile_id, role_id, is_primary))
|
||||
if role_id == "spare":
|
||||
for policy in config.roles.values():
|
||||
policy.preferred_chain = [item for item in policy.preferred_chain if item != profile_id]
|
||||
return True, "spare"
|
||||
chain = config.roles[role_id].preferred_chain
|
||||
chain = [item for item in chain if item != profile_id]
|
||||
if is_primary:
|
||||
chain.insert(0, profile_id)
|
||||
else:
|
||||
chain.append(profile_id)
|
||||
config.roles[role_id].preferred_chain = chain
|
||||
return True, "assigned"
|
||||
|
||||
monkeypatch.setattr(team_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
|
||||
|
||||
ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"])
|
||||
|
||||
assert ok
|
||||
assert config.roles["orchestrator"].preferred_chain == ["c", "a"]
|
||||
assert config.roles["reviewer"].preferred_chain == ["b"]
|
||||
assert ("b", "spare", False) in calls
|
||||
|
||||
|
||||
def test_account_action_result_is_sent_to_originating_card():
|
||||
calls = []
|
||||
accounts = SimpleNamespace(
|
||||
show_action_result=lambda profile_id, message, success: calls.append(
|
||||
("card", profile_id, message, success)
|
||||
)
|
||||
)
|
||||
fake = SimpleNamespace(
|
||||
_views={"accounts": accounts},
|
||||
_show_toast=lambda message: calls.append(("toast", message)),
|
||||
)
|
||||
|
||||
app_module.HermesHubApp._show_account_action_result(fake, "profile-1", "Не найден", False)
|
||||
|
||||
assert calls[0] == ("card", "profile-1", "Не найден", False)
|
||||
assert calls[1][0] == "toast"
|
||||
|
||||
|
||||
def test_device_code_step_contains_numbered_instructions_and_copy_actions():
|
||||
source = Path(wizard_module.__file__).read_text(encoding="utf-8")
|
||||
assert "1. Откройте ссылку" in source
|
||||
assert "2. Введите на странице код:" in source
|
||||
assert "3. Подтвердите доступ" in source
|
||||
assert source.count('text="Копировать ссылку"') == 2
|
||||
assert source.count('text="📋 Копировать код"') == 2
|
||||
|
||||
|
||||
def test_grok_slot_is_registered_before_role_assignment(monkeypatch):
|
||||
config = SimpleNamespace(profiles={})
|
||||
saved = []
|
||||
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
"antigravity_provider.router.auto_assigner.load_router_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"antigravity_provider.router.auto_assigner.save_router_config",
|
||||
lambda current: saved.append(current) or True,
|
||||
)
|
||||
|
||||
ok, _message = wizard_module.AutoAssigner.ensure_profile_definition("grok", "grok-orch")
|
||||
|
||||
assert ok
|
||||
assert config.profiles["grok-orch"].provider == "grok"
|
||||
assert config.profiles["grok-orch"].preferred_models[0] == "grok-3"
|
||||
assert saved == [config]
|
||||
|
||||
|
||||
def test_opencode_paste_targets_entry_and_reports_success():
|
||||
calls = []
|
||||
entry = SimpleNamespace(
|
||||
clipboard_get=lambda: " opencode-token-123 ",
|
||||
delete=lambda *_args: calls.append("delete"),
|
||||
insert=lambda *_args: calls.append(("insert", _args[-1])),
|
||||
focus_set=lambda: calls.append("focus"),
|
||||
icursor=lambda *_args: calls.append("cursor"),
|
||||
)
|
||||
status = SimpleNamespace(configure=lambda **kwargs: calls.append(("status", kwargs["text"])))
|
||||
fake = SimpleNamespace(key_entry=entry, key_status_lbl=status)
|
||||
|
||||
wizard_module.AddAccountWizard._paste_into_entry(fake, entry)
|
||||
|
||||
assert ("insert", "opencode-token-123") in calls
|
||||
assert ("status", "✓ Ключ вставлен. Нажмите «Проверить и продолжить».") in calls
|
||||
|
|
|
|||
Loading…
Reference in a new issue