Merge remote-tracking branch 'origin/antigravity/interface-cleanup'

This commit is contained in:
Hermes Team 2026-08-23 15:27:43 +07:00
commit 9c0da36f65
9 changed files with 364 additions and 40 deletions

View file

@ -0,0 +1,89 @@
"""Live screenshot capture script for A12 acceptance criteria."""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
import customtkinter as ctk
from antigravity_provider.router.hermes_hub_app import HermesHubApp
from antigravity_provider.router.ui.theme import Theme
def run_capture(output_dir: Path):
output_dir.mkdir(parents=True, exist_ok=True)
os.environ["HERMES_HUB_DEV_MODE"] = "1"
app = HermesHubApp()
app.geometry("1366x800+30+30")
app.deiconify()
app.lift()
app.attributes("-topmost", True)
# Allow startup refresh
for _ in range(15):
app.update()
time.sleep(0.05)
captured = []
def snap(name: str):
app.update_idletasks()
app.update()
time.sleep(0.1)
target = output_dir / f"{name}.png"
try:
from PIL import ImageGrab
left = app.winfo_rootx()
top = app.winfo_rooty()
ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target)
captured.append(target)
print(f"Captured: {target}")
except Exception as exc:
print(f"Screen grab not available in current session ({exc}); view {name} rendered successfully.")
try:
app._apply_theme("dark")
# 1. Accounts view with compact cards and visible quotas
app._show_view("accounts")
snap("01_accounts_compact_view")
# 2. Overview dashboard with 5 providers
app._show_view("overview")
snap("02_overview_all_providers")
# 3. Providers & models view
app._show_view("providers")
snap("03_providers_and_models")
# 4. Routing view
app._show_view("routing")
snap("04_routing_chains")
# 5. Team view
app._show_view("team")
snap("05_team_view")
# 6. Grok wizard step
app._handle_action("add_account", {})
app.update_idletasks()
app.update()
time.sleep(0.1)
snap("06_wizard_step1_providers")
finally:
app.attributes("-topmost", False)
app._shutting_down = True
app.destroy()
return captured
if __name__ == "__main__":
dest = Path("artifacts/a12-screenshots")
res = run_capture(dest)
print(f"Total screenshots completed: {len(res)}")

View file

@ -109,6 +109,19 @@ class ModelDiscoveryService:
"has_cache": True,
}
def get_cached(self, provider: str) -> Dict[str, Any]:
"""Convenience alias for get_models_with_metadata."""
return self.get_models_with_metadata(provider)
def refresh_models(
self,
provider: str,
on_complete: Optional[Callable[[Optional[List[str]]], None]] = None,
timeout: float = 15.0,
) -> None:
"""Trigger background model discovery with strict timeout (alias)."""
return self.refresh_models_async(provider, on_complete=on_complete, timeout=timeout)
# ─────────────────────────────────────────────────────────────
# DISCOVERY PROBES WITH TIMEOUT
# ─────────────────────────────────────────────────────────────

View file

@ -914,21 +914,23 @@ class AccountCardWidget(HubCard):
self.compact_quota = ctk.CTkFrame(self, fg_color="transparent")
self.compact_quota_cells: list[dict[str, Any]] = []
for index in range(1):
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")
cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 8), pady=(1, 2))
top_line = ctk.CTkFrame(cell, fg_color="transparent")
top_line.pack(fill="x")
title = ctk.CTkLabel(top_line, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY, anchor="w")
title.pack(side="left")
value = ctk.CTkLabel(top_line, text="", font=Theme.font_micro_bold(), text_color=Theme.TEXT_PRIMARY, anchor="e")
value.pack(side="right")
progress = ctk.CTkProgressBar(
cell,
height=5,
corner_radius=3,
height=4,
corner_radius=2,
fg_color=Theme.SURFACE_MUTED,
progress_color=Theme.STATUS_HEALTHY,
)
progress.pack(fill="x", pady=(2, 1))
progress.pack(fill="x", pady=(1, 0))
progress.set(0)
reset = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, anchor="w")
reset.pack(fill="x")
@ -1111,32 +1113,49 @@ class AccountCardWidget(HubCard):
getattr(bucket, "remaining_percent", None) is None,
float(getattr(bucket, "remaining_percent", 101.0) or 0.0),
),
)[:1]
for index, cell in enumerate(self.compact_quota_cells):
if index < len(visible_buckets):
bucket = visible_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()
)[:4]
if visible_buckets:
for index, cell in enumerate(self.compact_quota_cells):
if index < len(visible_buckets):
bucket = visible_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.STATUS_ERROR if getattr(bucket, "status", "unknown") == "exhausted" 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)
reset_txt = bucket.formatted_reset() or "Период указан провайдером"
cell["reset"].configure(text=reset_txt)
cell["frame"].grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 8), pady=(1, 2))
else:
cell["frame"].grid_remove()
else:
# Fallback when no bucket objects exist
reason = unavailable_reason or (
"Аккаунт не подключён" if profile.health_state in {"not_configured", "auth_required", "auth_expired"}
else "Провайдер не отдаёт лимиты"
)
cell = self.compact_quota_cells[0]
cell["title"].configure(text="Квота")
cell["value"].configure(text=f"Н", text_color=Theme.TEXT_MUTED)
cell["progress"].configure(progress_color=Theme.COLOR_NEUTRAL)
cell["progress"].set(0)
cell["reset"].configure(text=reason)
cell["frame"].grid(row=0, column=0, columnspan=2, sticky="nsew", padx=(0, 8), pady=(1, 2))
for other in self.compact_quota_cells[1:]:
other["frame"].grid_remove()
seen: set[str] = set()
for bucket in buckets:
key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket"))

View file

@ -359,6 +359,10 @@ class Theme:
def font_mono_sm(cls):
return (cls.FONT_FAMILY_MONO, 12)
@classmethod
def font_micro_bold(cls):
return (cls.FONT_FAMILY_UI, 10, "bold")
@classmethod
def font_badge_bold(cls):
return (cls.FONT_FAMILY_UI, 10, "bold")

View file

@ -14,8 +14,13 @@ from antigravity_provider.router.ui.theme import Theme
class ProviderCard(HubCard):
def __init__(self, master: Any):
super().__init__(master)
self.provider_id = ""
top = ctk.CTkFrame(self, fg_color="transparent")
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
self.icon = ctk.CTkLabel(top, text="", width=24, height=24)
self.icon.pack(side="left", padx=(0, Theme.SPACE_SM))
self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.updated = ctk.CTkLabel(top, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
@ -33,6 +38,11 @@ class ProviderCard(HubCard):
self.models.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
def update_provider(self, summary: Any) -> None:
self.provider_id = summary.provider_id
from antigravity_provider.router.ui.assets import AssetManager
img = AssetManager.get().get_provider_image(summary.provider_id, size=(24, 24))
self.icon.configure(image=img, text="" if img else "")
self.icon.image = img
self.title.configure(text=summary.provider_name)
self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н/Д — обнаружение ещё не запускалось'}")
self.stats.configure(

View file

@ -0,0 +1,97 @@
"""End-to-end verification for Grok and Claude connection, assignment, and testing."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
from antigravity_provider.router.hermes_hub_app import do_test_profile
@pytest.mark.unit
def test_grok_and_claude_free_slots_discovery():
"""Verify free slots for grok and claude can be found from default slots."""
config = RouterConfig(
profiles={
"grok-worker-1": RouterProfileConfig(profile_id="grok-worker-1", provider="grok", enabled=True),
"claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", enabled=True),
}
)
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config):
grok_slot = AutoAssigner.find_free_slot("grok")
assert grok_slot == "grok-worker-1"
claude_slot = AutoAssigner.find_free_slot("claude")
assert claude_slot == "claude-orch"
@pytest.mark.unit
def test_grok_wizard_definition_and_routing_flow():
"""Verify Grok profile definition and routing pipeline addition."""
config = RouterConfig(
profiles={},
roles={"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=[])},
)
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
assert ok_def, f"Definition failed: {msg_def}"
assert "grok-worker-1" in config.profiles
assert config.profiles["grok-worker-1"].provider == "grok"
ok_route, msg_route = ensure_profile_in_routing("grok-worker-1")
assert ok_route, f"Routing failed: {msg_route}"
assert "grok-worker-1" in config.roles["coder-primary"].preferred_chain
@pytest.mark.unit
def test_claude_wizard_definition_and_routing_flow():
"""Verify Claude profile definition and routing pipeline addition."""
config = RouterConfig(
profiles={},
roles={"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=[])},
)
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
assert ok_def, f"Definition failed: {msg_def}"
assert "claude-orch" in config.profiles
assert config.profiles["claude-orch"].provider == "claude"
ok_route, msg_route = ensure_profile_in_routing("claude-orch")
assert ok_route, f"Routing failed: {msg_route}"
assert "claude-orch" in config.roles["orchestrator"].preferred_chain
@pytest.mark.unit
def test_do_test_profile_for_grok_and_claude():
"""Verify profile local test helper handles grok and claude profiles."""
config = RouterConfig(
profiles={
"grok-worker-1": RouterProfileConfig(profile_id="grok-worker-1", provider="grok", preferred_models=["grok-beta"]),
"claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]),
}
)
mock_adapter = MagicMock()
mock_adapter.health_check.return_value = True
with patch("antigravity_provider.router.hermes_hub_app.load_router_config", return_value=config), \
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
patch("antigravity_provider.router.hermes_hub_app.get_adapter", return_value=mock_adapter):
res_grok = do_test_profile("grok", "grok-worker-1")
assert res_grok["success"] is True
assert res_grok["model"] == "grok-beta"
res_claude = do_test_profile("claude", "claude-orch")
assert res_claude["success"] is True
assert res_claude["model"] == "claude-3-5-sonnet"

View file

@ -351,3 +351,46 @@ def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
)
assert snapshot.buckets[0].remaining_percent is None
assert snapshot.unavailable_reason == "Авторизация недоступна"
@pytest.mark.ui
def test_dashboard_renders_all_five_providers_in_order(ui_root) -> None:
provider_ids = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]
providers = [
ProviderSummary(
provider_id=pid,
provider_name=pid.upper(),
total_slots=2,
connected_count=1,
online_count=1,
auth_required_count=0,
quota_exhausted_count=0,
cold_spare_count=0,
discovered_models=["model-1"],
last_refresh_at="12:00:00",
)
for pid in provider_ids
]
snapshot = HubSnapshot(
generation=1,
seq=1,
timestamp=time.time(),
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[],
providers=providers,
routing={},
quotas={},
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
assert len(view._provider_cards) == 5
assert set(view._provider_cards.keys()) == set(provider_ids)
for slot in view.route_diagram.provider_slots[:5]:
assert slot.winfo_manager() == "place"
finally:
view.destroy()

View file

@ -0,0 +1,47 @@
"""Test ensuring ModelDiscoveryService reachability via the agreed entrypoint."""
from __future__ import annotations
import pytest
@pytest.mark.unit
def test_model_discovery_entrypoint_reaches_service_directly():
"""Verify that router/model_discovery.py directly exports ModelDiscoveryService with .get()."""
try:
import antigravity_provider.router.model_discovery as md_module
except ImportError as exc:
pytest.fail(f"Could not import agreed module 'antigravity_provider.router.model_discovery': {exc}")
assert hasattr(
md_module, "ModelDiscoveryService"
), "Module 'antigravity_provider.router.model_discovery' must expose 'ModelDiscoveryService'"
from antigravity_provider.router.model_discovery import ModelDiscoveryService
assert hasattr(
ModelDiscoveryService, "get"
), "ModelDiscoveryService must provide a '.get()' classmethod/factory"
assert callable(ModelDiscoveryService.get), "ModelDiscoveryService.get must be callable"
service = ModelDiscoveryService.get()
assert service is not None, "ModelDiscoveryService.get() must return an initialized instance"
assert isinstance(
service, ModelDiscoveryService
), "ModelDiscoveryService.get() must return an instance of ModelDiscoveryService"
assert service is ModelDiscoveryService.get(), "ModelDiscoveryService.get() must return a singleton instance"
@pytest.mark.unit
def test_model_discovery_service_methods_and_cache_interface():
"""Verify standard methods of ModelDiscoveryService instance."""
from antigravity_provider.router.model_discovery import ModelDiscoveryService
service = ModelDiscoveryService.get()
assert hasattr(service, "get_cached"), "ModelDiscoveryService must implement get_cached()"
assert hasattr(service, "refresh_models"), "ModelDiscoveryService must implement refresh_models()"
# Test cache read for a standard provider
cached = service.get_cached("antigravity")
assert isinstance(cached, dict), "get_cached must return a dictionary"
assert "models" in cached, "cached payload must contain 'models' key"

View file

@ -19,7 +19,7 @@ from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
@pytest.fixture(scope="module")
def root():
def ui_root():
app = ctk.CTk()
app.withdraw()
yield app
@ -38,9 +38,10 @@ def _wizard(root, on_complete=None):
return w
def test_finish_closes_wizard_and_reports_result(root):
@pytest.mark.ui
def test_finish_closes_wizard_and_reports_result(ui_root):
seen = []
w = _wizard(root, on_complete=seen.append)
w = _wizard(ui_root, on_complete=seen.append)
w._finish()
@ -50,13 +51,14 @@ def test_finish_closes_wizard_and_reports_result(root):
]
def test_finish_closes_even_if_callback_raises(root):
@pytest.mark.ui
def test_finish_closes_even_if_callback_raises(ui_root):
"""Сбой в обработчике владельца не должен запирать пользователя в мастере."""
def boom(_result):
raise RuntimeError("обновление данных упало")
w = _wizard(root, on_complete=boom)
w = _wizard(ui_root, on_complete=boom)
w._finish()