feat(a32): complete removal of legacy desktop UI and migrate to pure web stack with 100% feature parity

This commit is contained in:
Hermes Team 2026-08-26 09:42:57 +07:00
parent 59d57a41ef
commit 6e544d96f2
48 changed files with 569 additions and 9998 deletions

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Drawing; using System.Drawing;
@ -137,7 +137,7 @@ namespace HermesHubSetup
{ {
ProcessStartInfo checkPsi = new ProcessStartInfo(); ProcessStartInfo checkPsi = new ProcessStartInfo();
checkPsi.FileName = pythonExe; checkPsi.FileName = pythonExe;
checkPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil, fastapi, uvicorn; print('DEPS_OK')\""; checkPsi.Arguments = "-c \"import yaml, psutil, fastapi, uvicorn; print('DEPS_OK')\"";
checkPsi.UseShellExecute = false; checkPsi.UseShellExecute = false;
checkPsi.RedirectStandardOutput = true; checkPsi.RedirectStandardOutput = true;
checkPsi.RedirectStandardError = true; checkPsi.RedirectStandardError = true;
@ -159,7 +159,7 @@ namespace HermesHubSetup
if (needsInstall) if (needsInstall)
{ {
if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (customtkinter, Pillow, PyYAML, psutil, FastAPI, uvicorn)...", 40); if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (FastAPI, uvicorn, PyYAML, psutil)...", 40);
// 1. Ensure pip is installed/bootstrapped if needed // 1. Ensure pip is installed/bootstrapped if needed
try try
@ -185,7 +185,7 @@ namespace HermesHubSetup
{ {
ProcessStartInfo pipPsi = new ProcessStartInfo(); ProcessStartInfo pipPsi = new ProcessStartInfo();
pipPsi.FileName = pythonExe; pipPsi.FileName = pythonExe;
pipPsi.Arguments = "-m pip install --no-warn-script-location customtkinter pillow pyyaml psutil fastapi uvicorn"; pipPsi.Arguments = "-m pip install --no-warn-script-location pyyaml psutil fastapi uvicorn";
pipPsi.UseShellExecute = false; pipPsi.UseShellExecute = false;
pipPsi.RedirectStandardOutput = true; pipPsi.RedirectStandardOutput = true;
pipPsi.RedirectStandardError = true; pipPsi.RedirectStandardError = true;
@ -209,7 +209,7 @@ namespace HermesHubSetup
{ {
ProcessStartInfo uvPsi = new ProcessStartInfo(); ProcessStartInfo uvPsi = new ProcessStartInfo();
uvPsi.FileName = "uv"; uvPsi.FileName = "uv";
uvPsi.Arguments = string.Format("pip install --python \"{0}\" customtkinter pillow pyyaml psutil fastapi uvicorn", pythonExe); uvPsi.Arguments = string.Format("pip install --python \"{0}\" pyyaml psutil fastapi uvicorn", pythonExe);
uvPsi.UseShellExecute = false; uvPsi.UseShellExecute = false;
uvPsi.RedirectStandardOutput = true; uvPsi.RedirectStandardOutput = true;
uvPsi.RedirectStandardError = true; uvPsi.RedirectStandardError = true;
@ -237,7 +237,7 @@ namespace HermesHubSetup
{ {
ProcessStartInfo recheckPsi = new ProcessStartInfo(); ProcessStartInfo recheckPsi = new ProcessStartInfo();
recheckPsi.FileName = pythonExe; recheckPsi.FileName = pythonExe;
recheckPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil, fastapi, uvicorn; print('DEPS_VERIFIED')\""; recheckPsi.Arguments = "-c \"import yaml, psutil, fastapi, uvicorn; print('DEPS_VERIFIED')\"";
recheckPsi.UseShellExecute = false; recheckPsi.UseShellExecute = false;
recheckPsi.RedirectStandardOutput = true; recheckPsi.RedirectStandardOutput = true;
recheckPsi.RedirectStandardError = true; recheckPsi.RedirectStandardError = true;
@ -277,17 +277,10 @@ namespace HermesHubSetup
// 1. Copy Application Binaries // 1. Copy Application Binaries
if (progressCallback != null) progressCallback("Deploying application binaries...", 20); if (progressCallback != null) progressCallback("Deploying application binaries...", 20);
string launcherSrc = Path.Combine(sourceRoot, @"launcher\HermesHub.exe"); string oldDesktopExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
if (!File.Exists(launcherSrc)) if (File.Exists(oldDesktopExe)) { try { File.Delete(oldDesktopExe); } catch { } }
{ string oldDesktopHomeExe = Path.Combine(HermesHome, "HermesHub.exe");
launcherSrc = Path.Combine(sourceRoot, "HermesHub.exe"); if (File.Exists(oldDesktopHomeExe)) { try { File.Delete(oldDesktopHomeExe); } catch { } }
}
if (File.Exists(launcherSrc))
{
File.Copy(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"), true);
File.Copy(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"), true);
}
string webLauncherSrc = Path.Combine(sourceRoot, @"launcher\HermesHubWeb.exe"); string webLauncherSrc = Path.Combine(sourceRoot, @"launcher\HermesHubWeb.exe");
if (!File.Exists(webLauncherSrc)) if (!File.Exists(webLauncherSrc))
@ -309,7 +302,7 @@ namespace HermesHubSetup
} }
// 2. Install UI & System Dependencies into Hermes Python Environment // 2. Install UI & System Dependencies into Hermes Python Environment
if (progressCallback != null) progressCallback("Checking Python dependencies (customtkinter, Pillow, FastAPI, uvicorn)...", 35); if (progressCallback != null) progressCallback("Checking Python dependencies (FastAPI, uvicorn, psutil, PyYAML)...", 35);
if (!EnsurePythonDependencies(HermesPython, progressCallback)) if (!EnsurePythonDependencies(HermesPython, progressCallback))
{ {
return 13; // Dependency install failed return 13; // Dependency install failed
@ -376,7 +369,7 @@ namespace HermesHubSetup
// 8. Post-install Verification & Import Smoke Test // 8. Post-install Verification & Import Smoke Test
if (progressCallback != null) progressCallback("Running post-install import validation...", 90); if (progressCallback != null) progressCallback("Running post-install import validation...", 90);
string pluginSrcDir = Path.Combine(HermesHome, @"plugins\antigravity-provider\src"); string pluginSrcDir = Path.Combine(HermesHome, @"plugins\antigravity-provider\src");
string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); import customtkinter; from PIL import Image; import antigravity_provider.router.hermes_hub_app; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir); string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); from antigravity_provider.router.web.server import app; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
ProcessStartInfo smokePsi = new ProcessStartInfo(); ProcessStartInfo smokePsi = new ProcessStartInfo();
smokePsi.FileName = HermesPython; smokePsi.FileName = HermesPython;
smokePsi.Arguments = smokeCmd; smokePsi.Arguments = smokeCmd;
@ -549,52 +542,32 @@ namespace HermesHubSetup
string targetWebExe = Path.Combine(TargetInstallDir, "HermesHubWeb.exe"); string targetWebExe = Path.Combine(TargetInstallDir, "HermesHubWeb.exe");
if (!File.Exists(targetWebExe)) targetWebExe = Path.Combine(HermesHome, "HermesHubWeb.exe"); if (!File.Exists(targetWebExe)) targetWebExe = Path.Combine(HermesHome, "HermesHubWeb.exe");
string targetDesktopExe = Path.Combine(TargetInstallDir, "HermesHub.exe"); // Clean up legacy shortcuts
if (!File.Exists(targetDesktopExe)) targetDesktopExe = Path.Combine(HermesHome, "HermesHub.exe"); string[] legacyShortcuts = new string[]
{
"Hermes Hub (Desktop).lnk",
"Hermes Hub (Web).lnk",
"Hermes Hub Web.lnk"
};
foreach (string legacy in legacyShortcuts)
{
string p = Path.Combine(startMenu, legacy);
if (File.Exists(p)) { try { File.Delete(p); } catch { } }
}
Type shellType = Type.GetTypeFromProgID("WScript.Shell"); Type shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType != null) if (shellType != null && File.Exists(targetWebExe))
{ {
dynamic shell = Activator.CreateInstance(shellType); dynamic shell = Activator.CreateInstance(shellType);
// 1. Web Application Window Shortcut (Primary Web App mode)
if (File.Exists(targetWebExe))
{
string webShortcutPath = Path.Combine(startMenu, "Hermes Hub (Web).lnk");
dynamic webShortcut = shell.CreateShortcut(webShortcutPath);
webShortcut.TargetPath = targetWebExe;
webShortcut.WorkingDirectory = TargetInstallDir;
webShortcut.Description = "Hermes Hub — Web Application Window";
webShortcut.IconLocation = targetWebExe + ",0";
webShortcut.Save();
}
// 2. Native Desktop Shortcut (CustomTkinter GUI)
if (File.Exists(targetDesktopExe))
{
string desktopShortcutPath = Path.Combine(startMenu, "Hermes Hub (Desktop).lnk");
dynamic desktopShortcut = shell.CreateShortcut(desktopShortcutPath);
desktopShortcut.TargetPath = targetDesktopExe;
desktopShortcut.WorkingDirectory = TargetInstallDir;
desktopShortcut.Description = "Hermes Hub — Native Desktop App";
desktopShortcut.IconLocation = targetDesktopExe + ",0";
desktopShortcut.Save();
}
// 3. Standard "Hermes Hub.lnk" shortcut
string standardTarget = File.Exists(targetWebExe) ? targetWebExe : targetDesktopExe;
if (File.Exists(standardTarget))
{
string standardShortcutPath = Path.Combine(startMenu, "Hermes Hub.lnk"); string standardShortcutPath = Path.Combine(startMenu, "Hermes Hub.lnk");
dynamic standardShortcut = shell.CreateShortcut(standardShortcutPath); dynamic standardShortcut = shell.CreateShortcut(standardShortcutPath);
standardShortcut.TargetPath = standardTarget; standardShortcut.TargetPath = targetWebExe;
standardShortcut.WorkingDirectory = TargetInstallDir; standardShortcut.WorkingDirectory = TargetInstallDir;
standardShortcut.Description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"; standardShortcut.Description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent";
standardShortcut.IconLocation = standardTarget + ",0"; standardShortcut.IconLocation = targetWebExe + ",0";
standardShortcut.Save(); standardShortcut.Save();
} }
} }
}
catch { } catch { }
} }
@ -634,7 +607,7 @@ namespace HermesHubSetup
key.SetValue("Publisher", "Hermes Team"); key.SetValue("Publisher", "Hermes Team");
key.SetValue("InstallLocation", TargetInstallDir); key.SetValue("InstallLocation", TargetInstallDir);
key.SetValue("UninstallString", string.Format("\"{0}\" /uninstall", Path.Combine(TargetInstallDir, "HermesHubSetup.exe"))); key.SetValue("UninstallString", string.Format("\"{0}\" /uninstall", Path.Combine(TargetInstallDir, "HermesHubSetup.exe")));
key.SetValue("DisplayIcon", Path.Combine(TargetInstallDir, "HermesHub.exe")); key.SetValue("DisplayIcon", Path.Combine(TargetInstallDir, "HermesHubWeb.exe"));
key.SetValue("NoModify", 1, RegistryValueKind.DWord); key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 0, RegistryValueKind.DWord); key.SetValue("NoRepair", 0, RegistryValueKind.DWord);
} }

View file

@ -33,8 +33,8 @@ def check_hermes_agent_installed() -> bool:
def verify_dependencies(venv_python: Path) -> bool: def verify_dependencies(venv_python: Path) -> bool:
"""Verify that required UI packages can be imported without error.""" """Verify that required web packages can be imported without error."""
code = "import customtkinter; from PIL import Image; import yaml; import psutil; print('OK')" code = "import fastapi; import uvicorn; import yaml; import psutil; print('OK')"
try: try:
res = subprocess.run( res = subprocess.run(
[str(venv_python), "-c", code], [str(venv_python), "-c", code],
@ -48,8 +48,8 @@ def verify_dependencies(venv_python: Path) -> bool:
def install_dependencies(venv_python: Path) -> bool: def install_dependencies(venv_python: Path) -> bool:
"""Install required UI packages into Hermes venv.""" """Install required web packages into Hermes venv."""
packages = ["customtkinter>=6.0.0", "pillow>=10.0.0", "psutil>=5.9.0", "pyyaml>=6.0.1", "requests>=2.31.0"] packages = ["fastapi>=0.110.0", "uvicorn>=0.28.0", "psutil>=5.9.0", "pyyaml>=6.0.1", "requests>=2.31.0"]
try: try:
res = subprocess.run( res = subprocess.run(
[str(venv_python), "-m", "pip", "install", "--upgrade"] + packages, [str(venv_python), "-m", "pip", "install", "--upgrade"] + packages,
@ -95,13 +95,13 @@ def run_installation(silent: bool = False):
print(f" ✓ Hermes Agent найден ({venv_python})") print(f" ✓ Hermes Agent найден ({venv_python})")
# 2. Dependency verification and install # 2. Dependency verification and install
print("\n[2/5] Проверка и установка зависимостей UI (customtkinter, Pillow)...") print("\n[2/5] Проверка и установка зависимостей (FastAPI, uvicorn, PyYAML, psutil)...")
if not verify_dependencies(venv_python): if not verify_dependencies(venv_python):
print(" Установка недостающих пакетов в окружение Hermes...") print(" Установка недостающих пакетов в окружение Hermes...")
ok = install_dependencies(venv_python) ok = install_dependencies(venv_python)
if not ok or not verify_dependencies(venv_python): if not ok or not verify_dependencies(venv_python):
print("\n" + "!" * 60) print("\n" + "!" * 60)
print(" [ОШИБКА УСТАНОВКИ] Не удалось установить зависимости GUI (customtkinter / Pillow)!") print(" [ОШИБКА УСТАНОВКИ] Не удалось установить зависимости (FastAPI / uvicorn / PyYAML / psutil)!")
print(" Пожалуйста, проверьте подключение к сети и права доступа к venv.") print(" Пожалуйста, проверьте подключение к сети и права доступа к venv.")
print("!" * 60 + "\n") print("!" * 60 + "\n")
if not silent: if not silent:

View file

@ -1,79 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows.Forms;
namespace HermesHub
{
static class Program
{
[STAThread]
static void Main(string[] args)
{
Application.EnableVisualStyles();
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string hermesHome = Path.Combine(localAppData, "hermes");
// Prefer pythonw.exe (no console flash) with UseShellExecute=true
// so tkinter can create GUI windows properly.
string hermesPythonW = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\pythonw.exe");
string hermesPython = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\python.exe");
string exe = File.Exists(hermesPythonW) ? hermesPythonW : hermesPython;
if (!File.Exists(exe))
{
MessageBox.Show(
"Hermes Python not found:\n" + exe + "\n\nPlease install Hermes Agent first.",
"Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
// Build sys.path and launch native GUI
string pluginSrc = Path.Combine(hermesHome, @"plugins\antigravity-provider\src");
string agentDir = Path.Combine(hermesHome, "hermes-agent");
string hubSrc = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\src"));
StringBuilder script = new StringBuilder();
script.AppendLine("import sys");
if (Directory.Exists(pluginSrc))
script.AppendLine("sys.path.insert(0, r'" + pluginSrc.Replace('\\', '/') + "')");
if (Directory.Exists(agentDir))
script.AppendLine("sys.path.insert(0, r'" + agentDir.Replace('\\', '/') + "')");
if (Directory.Exists(hubSrc))
script.AppendLine("sys.path.insert(0, r'" + hubSrc.Replace('\\', '/') + "')");
script.AppendLine("from antigravity_provider.router.launcher_bootstrap import bootstrap_and_launch");
script.AppendLine("bootstrap_and_launch()");
string entryScript = Path.Combine(hermesHome, "hermes_hub_entry.py");
try
{
File.WriteAllText(entryScript, script.ToString(), Encoding.UTF8);
}
catch (Exception ex)
{
MessageBox.Show("Cannot write entry script:\n" + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
// UseShellExecute=true is required for tkinter to display GUI windows.
// WindowStyle=Hidden hides the console (if python.exe is used instead of pythonw.exe).
ProcessStartInfo psi = new ProcessStartInfo();
psi.FileName = exe;
psi.Arguments = "\"" + entryScript + "\"";
psi.WorkingDirectory = hermesHome;
psi.UseShellExecute = true;
psi.WindowStyle = ProcessWindowStyle.Hidden;
try
{
Process.Start(psi);
}
catch (Exception ex)
{
MessageBox.Show("Failed to start Hermes Hub:\n" + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}

Binary file not shown.

View file

@ -37,8 +37,8 @@ dependencies = [
"pydantic>=2.6.0", "pydantic>=2.6.0",
"requests>=2.31.0", "requests>=2.31.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"customtkinter>=6.0.0", "fastapi>=0.110.0",
"pillow>=12.3.0", "uvicorn>=0.28.0",
"psutil>=5.9.0", "psutil>=5.9.0",
] ]
@ -49,10 +49,6 @@ dev = [
"anyio>=4.0.0", "anyio>=4.0.0",
"ruff>=0.3.0", "ruff>=0.3.0",
] ]
web = [
"fastapi>=0.110.0",
"uvicorn>=0.28.0",
]
[project.scripts] [project.scripts]
hermes-hub = "antigravity_provider.router.cli_commands:main" hermes-hub = "antigravity_provider.router.cli_commands:main"

View file

@ -17,7 +17,7 @@ if not exist "%HERMES_PYTHON%" (
set "PYTHONPATH=%LOCALAPPDATA%\hermes\plugins\antigravity-provider\src;%~dp0..\plugins\antigravity-provider\src;%PYTHONPATH%" set "PYTHONPATH=%LOCALAPPDATA%\hermes\plugins\antigravity-provider\src;%~dp0..\plugins\antigravity-provider\src;%PYTHONPATH%"
echo Starting Hermes Hub Server on http://127.0.0.1:8765 ... echo Starting Hermes Hub Server on http://127.0.0.1:5800 ...
"%HERMES_PYTHON%" -m antigravity_provider.router.cli_commands hub --port 8765 "%HERMES_PYTHON%" -m antigravity_provider.router.cli_commands web --port 5800
endlocal endlocal

View file

@ -254,7 +254,7 @@ def print_diagnostics_cli() -> int:
reasons.append(f"Отсутствуют зависимости: {', '.join(missing_deps)}") reasons.append(f"Отсутствуют зависимости: {', '.join(missing_deps)}")
has_fatal_error = True has_fatal_error = True
else: else:
print("[PASS] Зависимости venv: все необходимые пакеты установлены (customtkinter, Pillow, psutil, pyyaml)") print("[PASS] Зависимости venv: все необходимые пакеты установлены (FastAPI, uvicorn, psutil, pyyaml)")
# 2. Deployed Plugin Freshness Check # 2. Deployed Plugin Freshness Check
hermes_home = paths.get_hermes_home() hermes_home = paths.get_hermes_home()
@ -419,8 +419,15 @@ def clear_cooldown_cli(profile_id: Optional[str] = None) -> int:
return 0 return 0
def run_web_server(port: Optional[int] = None) -> int:
"""Launch Hermes Hub Web Server."""
from antigravity_provider.router.web.server import run_server
run_server()
return 0
def main(argv: Optional[list[str]] = None) -> int: def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(prog="hermes router", description="Hermes Multi-Provider Account Router CLI") parser = argparse.ArgumentParser(prog="hermes-hub", description="Hermes Multi-Provider Account Router CLI")
subparsers = parser.add_subparsers(dest="subcommand", help="Router subcommands") subparsers = parser.add_subparsers(dest="subcommand", help="Router subcommands")
# status # status
@ -470,17 +477,14 @@ def main(argv: Optional[list[str]] = None) -> int:
cc_parser = subparsers.add_parser("clear-cooldown", help="Clear cooldowns and quota simulations") cc_parser = subparsers.add_parser("clear-cooldown", help="Clear cooldowns and quota simulations")
cc_parser.add_argument("profile_id", nargs="?", default=None, help="Optional profile ID") cc_parser.add_argument("profile_id", nargs="?", default=None, help="Optional profile ID")
# hub / cockpit / gui # web / hub / server
hub_parser = subparsers.add_parser("hub", aliases=["cockpit", "gui"], help="Launch Hermes Hub GUI") hub_parser = subparsers.add_parser("web", aliases=["hub", "server", "cockpit", "gui"], help="Launch Hermes Hub Web Server")
hub_parser.add_argument("--port", type=int, default=8765, help="Port to bind server (default 8765)") hub_parser.add_argument("--port", type=int, default=5800, help="Port to bind server (default 5800)")
hub_parser.add_argument("--no-browser", action="store_true", help="Do not automatically open browser")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.subcommand in ("hub", "cockpit", "gui"): if args.subcommand in ("web", "hub", "server", "cockpit", "gui") or not args.subcommand:
from antigravity_provider.router.hermes_hub_app import launch_hub return run_web_server(port=getattr(args, "port", None))
launch_hub()
return 0
elif args.subcommand == "status": elif args.subcommand == "status":
return print_router_status() return print_router_status()
elif args.subcommand == "diag": elif args.subcommand == "diag":

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,13 @@
Responsibilities: Responsibilities:
1. Dependency Verification & Self-Healing: 1. Dependency Verification & Self-Healing:
Checks for required packages (customtkinter, pillow, psutil, pyyaml, requests). Checks for required packages (fastapi, uvicorn, psutil, pyyaml, requests).
If any package is missing, attempts non-blocking silent auto-installation into the active Python environment. If any package is missing, attempts non-blocking silent auto-installation into the active Python environment.
2. Pre-UI Crash Logging: 2. Pre-Server Crash Logging:
Ensures all startup lifecycle stages and any early unhandled exceptions are written to Ensures all startup lifecycle stages and any early unhandled exceptions are written to
logs/startup.log with full traceback before GUI initialization. logs/startup.log with full traceback before server initialization.
3. Native User Feedback: 3. Native User Feedback:
If a fatal crash occurs before a window can be displayed, shows a native Windows error dialog If a fatal crash occurs before the server is initialized, shows a native Windows error dialog
pointing to the exact log file location instead of silent process termination. pointing to the exact log file location instead of silent process termination.
""" """
from __future__ import annotations from __future__ import annotations
@ -58,15 +58,15 @@ def show_native_error(title: str, message: str) -> None:
REQUIRED_PACKAGES: Dict[str, str] = { REQUIRED_PACKAGES: Dict[str, str] = {
"customtkinter": "customtkinter>=6.0.0", "fastapi": "fastapi>=0.110.0",
"PIL": "pillow>=10.0.0", "uvicorn": "uvicorn>=0.28.0",
"psutil": "psutil>=5.9.0", "psutil": "psutil>=5.9.0",
"yaml": "pyyaml>=6.0.1", "yaml": "pyyaml>=6.0.1",
} }
def check_missing_dependencies() -> List[str]: def check_missing_dependencies() -> List[str]:
"""Check which required UI / system packages are currently unimportable.""" """Check which required web / system packages are currently unimportable."""
missing = [] missing = []
for mod_name, pkg_spec in REQUIRED_PACKAGES.items(): for mod_name, pkg_spec in REQUIRED_PACKAGES.items():
try: try:
@ -118,7 +118,7 @@ def self_heal_dependencies(missing_packages: List[str]) -> Tuple[bool, str]:
def bootstrap_and_launch() -> None: def bootstrap_and_launch() -> None:
"""Bootstrap entry point: log startup, verify dependencies, and launch Hermes Hub GUI.""" """Bootstrap entry point: log startup, verify dependencies, and launch Hermes Hub Web Server."""
log_startup("=== Hermes Hub Launcher Bootstrap initiated ===") log_startup("=== Hermes Hub Launcher Bootstrap initiated ===")
log_startup(f"Python: {sys.executable} (version {sys.version.split()[0]})") log_startup(f"Python: {sys.executable} (version {sys.version.split()[0]})")
log_startup(f"Working Directory: {os.getcwd()}") log_startup(f"Working Directory: {os.getcwd()}")
@ -132,29 +132,29 @@ def bootstrap_and_launch() -> None:
log_path = get_startup_log_path() log_path = get_startup_log_path()
show_native_error( show_native_error(
"Hermes Hub — Ошибка компонентов", "Hermes Hub — Ошибка компонентов",
"Не удалось автоматически установить необходимые компоненты графического интерфейса (customtkinter / Pillow / psutil).\n\n" "Не удалось автоматически установить необходимые компоненты (FastAPI / uvicorn / psutil / PyYAML).\n\n"
f"Детали ошибки: {detail}\n\n" f"Детали ошибки: {detail}\n\n"
f"Лог запуска: {log_path}\n\n" f"Лог запуска: {log_path}\n\n"
"Вы можете установить их вручную командой:\n" "Вы можете установить их вручную командой:\n"
f"{sys.executable} -m pip install customtkinter pillow psutil pyyaml", f"{sys.executable} -m pip install fastapi uvicorn psutil pyyaml",
) )
sys.exit(1) sys.exit(1)
# 2. Launch GUI with full exception capture # 2. Launch Web Server with full exception capture
try: try:
log_startup("Importing hermes_hub_app module...") log_startup("Importing web server module...")
from antigravity_provider.router.hermes_hub_app import launch_hub from antigravity_provider.router.web.server import run_server
log_startup("Executing launch_hub()...") log_startup("Executing run_server()...")
launch_hub() run_server()
log_startup("Hermes Hub GUI closed normally.") log_startup("Hermes Hub Web Server closed normally.")
except Exception as exc: except Exception as exc:
tb = traceback.format_exc() tb = traceback.format_exc()
log_startup(f"FATAL EXCEPTION during launch:\n{tb}") log_startup(f"FATAL EXCEPTION during launch:\n{tb}")
log_path = get_startup_log_path() log_path = get_startup_log_path()
show_native_error( show_native_error(
"Hermes Hub — Критическая ошибка при запуске", "Hermes Hub — Критическая ошибка при запуске",
f"Произошла ошибка при запуске интерфейса Hermes Hub:\n\n{exc}\n\n" f"Произошла ошибка при запуске веб-сервера Hermes Hub:\n\n{exc}\n\n"
f"Полный текст ошибки записан в лог:\n{log_path}", f"Полный текст ошибки записан в лог:\n{log_path}",
) )
sys.exit(1) sys.exit(1)

View file

@ -1,8 +0,0 @@
"""Hermes Hub UI Package — Native Dark-First Design System."""
from __future__ import annotations
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
__all__ = ["Theme", "AssetManager"]

File diff suppressed because it is too large Load diff

View file

@ -1,228 +0,0 @@
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
try:
from PIL import Image, ImageDraw
except ImportError:
Image = None
ImageDraw = None
try:
import customtkinter as ctk
except ImportError:
import unittest.mock as _mock
ctk = _mock.MagicMock()
class AssetManager:
_instance: Optional[AssetManager] = None
_image_cache: Dict[str, ctk.CTkImage] = {}
_pil_cache: Dict[str, Any] = {}
def __init__(self):
self.root_dir = self._find_repo_root()
self.branding_dir = self.root_dir / "assets" / "branding"
self.providers_dir = self.root_dir / "assets" / "providers"
self.logo_dir = self.branding_dir / "logo"
self.app_dir = self.branding_dir / "app"
self.splash_dir = self.branding_dir / "splash"
self.icons_dir = self.branding_dir / "icons"
@classmethod
def get(cls) -> AssetManager:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _find_repo_root(self) -> Path:
from antigravity_provider.paths import get_repo_root
return get_repo_root()
def get_ico_path(self) -> str:
ico = self.app_dir / "HermesHub.ico"
if ico.exists():
return str(ico)
alt = self.root_dir / "launcher" / "HermesHub.ico"
if alt.exists():
return str(alt)
return ""
@classmethod
def clear_cache(cls):
cls._image_cache.clear()
cls._pil_cache.clear()
def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]:
logo_path = self.logo_dir / "logo_approved.png"
if not logo_path.exists():
logo_path = self.logo_dir / "logo_256.png"
if not logo_path.exists():
logo_path = self.logo_dir / "logo_master.png"
if not logo_path.exists():
logo_path = self.branding_dir / "source" / "Hermes Hub.png"
if logo_path.exists():
try:
pil_img = Image.open(logo_path).convert("RGBA")
from antigravity_provider.router.ui.theme import Theme
transparent = pil_img.copy()
pixels = []
tint_gold = Theme.current_scheme in {"dark", "hybrid"}
dark_fill = tuple(int(Theme.BG_SIDEBAR[index : index + 2], 16) for index in (1, 3, 5))
pixel_data = (
transparent.get_flattened_data()
if hasattr(transparent, "get_flattened_data")
else transparent.getdata()
)
for red, green, blue, alpha in pixel_data:
if red > 232 and green > 230 and blue > 224:
pixels.append((red, green, blue, 0))
elif tint_gold and max(red, green, blue) < 105:
pixels.append((*dark_fill, alpha))
else:
pixels.append((red, green, blue, alpha))
transparent.putdata(pixels)
return ctk.CTkImage(light_image=transparent, dark_image=transparent, size=size)
except Exception:
return None
return None
def get_provider_image(self, provider: str, size: Tuple[int, int] = (20, 20)) -> Optional[ctk.CTkImage]:
"""Fetch cached CTkImage for AI providers (antigravity, openai, opencode)."""
prov_clean = provider.lower()
if "antigravity" in prov_clean:
key_name = "antigravity"
elif "codex" in prov_clean or "openai" in prov_clean:
key_name = "openai"
elif "opencode" in prov_clean:
key_name = "opencode"
else:
key_name = "antigravity"
cache_key = f"prov_{key_name}_{size[0]}x{size[1]}"
if cache_key in self._image_cache:
return self._image_cache[cache_key]
prov_folder = self.providers_dir / key_name
# Find best size matching
target_file = prov_folder / f"{key_name}_{size[0]}.png"
if not target_file.exists():
target_file = prov_folder / f"{key_name}_32.png"
if not target_file.exists():
target_file = prov_folder / f"{key_name}_master.png"
if target_file.exists():
try:
pil_img = Image.open(target_file).convert("RGBA")
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size)
except Exception:
return None
return None
def get_nav_icon(self, name: str, size: int = 20) -> Optional[ctk.CTkImage]:
"""Return a consistent gold outline icon for the sidebar."""
if Image is None or ImageDraw is None:
return None
from antigravity_provider.router.ui.theme import Theme
cache_key = f"nav_{Theme.current_scheme}_{name}_{size}"
if cache_key in self._image_cache:
return self._image_cache[cache_key]
scale = 4
canvas_size = size * scale
image = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
color = Theme.TEXT_ACCENT
width = 2 * scale
def points(values):
return tuple(tuple(int(coord * scale) for coord in point) for point in values)
def line(values, **kwargs):
draw.line(points(values), fill=color, width=width, joint="curve", **kwargs)
def ellipse(box):
draw.ellipse(tuple(int(value * scale) for value in box), outline=color, width=width)
def rectangle(box, radius=0):
scaled = tuple(int(value * scale) for value in box)
if radius:
draw.rounded_rectangle(scaled, radius=radius * scale, outline=color, width=width)
else:
draw.rectangle(scaled, outline=color, width=width)
if name == "overview":
line(((3, 10), (10, 4), (17, 10)))
line(((5, 9), (5, 17), (15, 17), (15, 9)))
line(((8, 17), (8, 12), (12, 12), (12, 17)))
elif name == "team":
ellipse((3, 4, 9, 10))
ellipse((11, 5, 16, 10))
draw.arc((1 * scale, 9 * scale, 12 * scale, 19 * scale), 190, 350, fill=color, width=width)
draw.arc((8 * scale, 10 * scale, 19 * scale, 19 * scale), 190, 350, fill=color, width=width)
elif name == "accounts":
draw.ellipse((3 * scale, 3 * scale, 17 * scale, 8 * scale), outline=color, width=width)
line(((3, 5.5), (3, 15), (5, 17), (15, 17), (17, 15), (17, 5.5)))
draw.arc((3 * scale, 8 * scale, 17 * scale, 13 * scale), 0, 180, fill=color, width=width)
elif name == "routing":
ellipse((2, 8, 6, 12))
ellipse((8, 2, 12, 6))
ellipse((14, 8, 18, 12))
ellipse((8, 14, 12, 18))
line(((6, 10), (14, 10)))
line(((10, 6), (10, 14)))
elif name == "orchestrator":
ellipse((6, 6, 14, 14))
for box in ((2, 2, 5, 5), (15, 2, 18, 5), (2, 15, 5, 18), (15, 15, 18, 18)):
ellipse(box)
line(((5, 5), (7, 7)))
line(((15, 5), (13, 7)))
line(((5, 15), (7, 13)))
line(((15, 15), (13, 13)))
elif name == "providers":
draw.arc((2 * scale, 7 * scale, 18 * scale, 17 * scale), 175, 365, fill=color, width=width)
draw.arc((5 * scale, 2 * scale, 14 * scale, 13 * scale), 190, 350, fill=color, width=width)
line(((4, 16), (16, 16)))
elif name == "quotas":
line(((10, 2), (17, 5), (16, 13), (10, 18), (4, 13), (3, 5), (10, 2)))
line(((7, 10), (9, 12), (13, 7)))
elif name == "analytics":
rectangle((3, 11, 6, 17))
rectangle((8.5, 7, 11.5, 17))
rectangle((14, 3, 17, 17))
line(((2, 18), (18, 18)))
elif name == "health":
line(((2, 11), (6, 11), (8, 5), (11, 16), (13, 9), (18, 9)))
elif name == "logs":
rectangle((4, 2, 16, 18), radius=1)
line(((7, 7), (13, 7)))
line(((7, 11), (13, 11)))
line(((7, 15), (11, 15)))
elif name == "incidents":
line(((10, 2), (18, 17), (2, 17), (10, 2)))
line(((10, 7), (10, 12)))
ellipse((9.3, 14, 10.7, 15.4))
elif name == "settings":
ellipse((7, 7, 13, 13))
for angle in range(0, 360, 45):
import math
start = (10 + 5 * math.cos(math.radians(angle)), 10 + 5 * math.sin(math.radians(angle)))
end = (10 + 8 * math.cos(math.radians(angle)), 10 + 8 * math.sin(math.radians(angle)))
line((start, end))
else:
ellipse((3, 3, 17, 17))
line(((10, 8), (10, 15)))
ellipse((9.3, 5, 10.7, 6.4))
image = image.resize((size, size), Image.Resampling.LANCZOS)
result = ctk.CTkImage(light_image=image, dark_image=image, size=(size, size))
self._image_cache[cache_key] = result
return result

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
"""Non-blocking UI adapter for the backend model-discovery cache.
The UI never discovers models synchronously and never invents model IDs. The
adapter deliberately tolerates the small naming differences used by backend
revisions so the presentation layer can be merged independently from A9.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass(frozen=True)
class CachedModels:
provider: str
models: tuple[str, ...] = ()
fetched_at: str = ""
is_stale: bool = True
unavailable_reason: str = "Список моделей ещё не получен"
def _service() -> Any:
try:
from antigravity_provider.router.model_discovery import ModelDiscoveryService
getter = getattr(ModelDiscoveryService, "get", None)
return getter() if callable(getter) else ModelDiscoveryService()
except (ImportError, AttributeError, TypeError):
pass
try:
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
getter = getattr(ModelDiscoveryService, "get", None)
return getter() if callable(getter) else ModelDiscoveryService()
except (ImportError, AttributeError, TypeError):
return None
def _normalise(provider: str, raw: Any) -> CachedModels:
if raw is None:
return CachedModels(provider=provider)
if isinstance(raw, dict):
models = raw.get("models") or raw.get("discovered_models") or []
fetched_at = raw.get("fetched_at") or raw.get("discovered_at") or raw.get("updated_at") or raw.get("last_refresh_at") or ""
stale = raw.get("is_stale", raw.get("stale", False))
reason = raw.get("unavailable_reason") or raw.get("error") or ""
else:
models = getattr(raw, "models", None) or getattr(raw, "discovered_models", None) or []
fetched_at = (
getattr(raw, "fetched_at", None)
or getattr(raw, "updated_at", None)
or getattr(raw, "last_refresh_at", None)
or ""
)
stale = getattr(raw, "is_stale", getattr(raw, "stale", False))
reason = getattr(raw, "unavailable_reason", None) or getattr(raw, "error", None) or ""
clean = tuple(dict.fromkeys(str(model).strip() for model in models if str(model).strip()))
return CachedModels(
provider=provider,
models=clean,
fetched_at=str(fetched_at),
is_stale=bool(stale),
unavailable_reason=str(reason or ("" if clean else "Список моделей ещё не получен")),
)
def get_cached_models(provider: str) -> CachedModels:
"""Return cached models immediately; never performs provider I/O."""
service = _service()
if service is None:
return CachedModels(provider=provider, unavailable_reason="Служба обнаружения моделей ещё не подключена")
for name in ("get_cached", "get_cached_models", "get_snapshot", "get_provider_models"):
method = getattr(service, name, None)
if not callable(method):
continue
try:
return _normalise(provider, method(provider))
except Exception as exc:
return CachedModels(provider=provider, unavailable_reason=f"Кэш моделей недоступен: {exc}")
return CachedModels(provider=provider, unavailable_reason="Служба не предоставляет чтение кэша моделей")
def refresh_models_async(provider: str, on_complete: Callable[[CachedModels], None]) -> bool:
"""Request a backend background refresh without blocking the Tk thread."""
service = _service()
if service is None:
on_complete(get_cached_models(provider))
return False
for name in ("refresh_models_async", "refresh_models", "refresh_provider_async", "refresh_async", "discover_async"):
method = getattr(service, name, None)
if not callable(method):
continue
try:
result: Optional[Any] = method(provider, on_complete=lambda *_args: on_complete(get_cached_models(provider)))
return result is not False
except TypeError:
try:
result = method(provider)
return result is not False
except Exception:
break
except Exception:
break
on_complete(get_cached_models(provider))
return False

View file

@ -1,274 +0,0 @@
"""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))
orch_node = next((n for n in ("manager", "orchestrator") if n in node_set), None)
if not orch_node:
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 orch_node:
visit(orch_node)
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 ())

View file

@ -1,78 +0,0 @@
"""Hermes Hub — Native Brand Splash Screen."""
from __future__ import annotations
import tkinter as tk
from typing import Optional
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
class SplashScreen(ctk.CTkToplevel):
"""Brand-compliant Splash Screen shown during initialization."""
def __init__(self, parent: ctk.CTk, width: int = 500, height: int = 300):
super().__init__(parent)
self.overrideredirect(True)
self.configure(fg_color=Theme.BG_WINDOW)
# Center on screen
self.update_idletasks()
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = (sw - width) // 2
y = (sh - height) // 2
self.geometry(f"{width}x{height}+{x}+{y}")
# Container
container = ctk.CTkFrame(
self,
corner_radius=Theme.RADIUS_LG,
border_width=2,
border_color=Theme.BORDER_ACCENT,
fg_color=Theme.BG_WINDOW,
)
container.pack(fill="both", expand=True, padx=2, pady=2)
# Logo
logo_img = AssetManager.get().get_splash_logo(size=(100, 100))
if logo_img:
ctk.CTkLabel(container, image=logo_img, text="").pack(pady=(28, 8))
ctk.CTkLabel(
container,
text="HERMES HUB",
font=Theme.font_title_hero(),
text_color=Theme.TEXT_ACCENT,
).pack(pady=(0, 2))
ctk.CTkLabel(
container,
text="Multi-Agent & Multi-Provider Control Hub",
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
).pack(pady=(0, 16))
# Progress indicator bar
self.progress = ctk.CTkProgressBar(
container,
width=300,
height=4,
corner_radius=2,
fg_color=Theme.SURFACE,
progress_color=Theme.ACCENT,
mode="indeterminate",
)
self.progress.pack(pady=(0, 20))
self.progress.start()
self.update()
def close(self):
try:
self.progress.stop()
self.destroy()
except Exception:
pass

View file

@ -1,379 +0,0 @@
"""Hermes Hub — Centralized Design Tokens and Theme Definition (v3).
Source of Truth: Brand Guidelines & Brandbook
Primary Palette:
PRIMARY: #0F1510 (Obsidian Forest)
DARK: #1A2A1F (Deep Pine)
SECONDARY: #2F4A36 (Moss Slate)
LIGHT: #F7F1E3 (Warm Ivory)
ACCENT: #CDAA64 (Ancient Gold)
"""
from __future__ import annotations
class Theme:
# ── Core Brand Palette ──
PRIMARY = "#0F1510"
DARK = "#1A2A1F"
SECONDARY = "#2F4A36"
LIGHT = "#F7F1E3"
ACCENT = "#CDAA64"
# ── Surfaces & Backgrounds ──
BG_WINDOW = "#0F1510"
BG_SIDEBAR = "#142018"
BG_HEADER = "#16241B"
BG_STATUSBAR = "#121C15"
BG_MODAL_BACKDROP = "#080B08"
SURFACE = "#1D3123"
SURFACE_HOVER = "#274230"
SURFACE_ACTIVE = "#31543D"
SURFACE_SELECTED = "#284432"
SURFACE_MUTED = "#16251A"
# ── Borders ──
BORDER = "#2F4A36"
BORDER_SUBTLE = "#1F3526"
BORDER_ACCENT = "#CDAA64"
BORDER_HOVER = "#40644B"
# ── Typography Colors ──
TEXT_PRIMARY = "#F7F1E3"
TEXT_SECONDARY = "#C5BEAF"
TEXT_MUTED = "#8A9C8E"
TEXT_ON_ACCENT = "#0F1510"
TEXT_ACCENT = "#CDAA64"
TEXT_DISABLED = "#546457"
# ── Accent States ──
ACCENT_HOVER = "#DCBE7D"
ACCENT_PRESSED = "#BA954E"
ACCENT_DIM = "#3D3522"
ACCENT_GLOW = "#CDAA64"
DANGER_SURFACE = "#5A1E1E"
DANGER_SURFACE_HOVER = "#7A2828"
DANGER_BORDER = "#8A3333"
DANGER_TEXT = "#FFD6D6"
# ── Operational / Status Colors ──
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
STATUS_WARNING = "#D97706" # Amber Gold
STATUS_ERROR = "#DC2626" # Ruby Crimson
STATUS_INFO = "#2563EB" # Azure
STATUS_AUTH_REQUIRED = "#D97706"
STATUS_DISABLED = "#5A6B5D"
STATUS_FAILOVER = "#3B82F6"
STATUS_MAIN = "#CDAA64" # Gold Badge
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
# Semantic roles. Brand gold is intentionally not a health colour.
COLOR_POSITIVE = STATUS_HEALTHY
COLOR_CAUTION = STATUS_WARNING
COLOR_NEGATIVE = STATUS_ERROR
COLOR_NEUTRAL = STATUS_DISABLED
COLOR_BRAND = ACCENT
# ── Provider Specific Accent Colors ──
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
PROVIDER_OPENCODE = "#F97316" # OpenCode Orange
PROVIDER_CLAUDE = "#D97706"
PROVIDER_GROK = "#3B82F6"
PROVIDER_GENERIC = "#8B5CF6"
SCHEMES = ("dark", "hybrid", "light")
SCHEME_LABELS = {
"dark": "Тёмная",
"hybrid": "Средняя (гибрид)",
"light": "Светлая (бежевая)",
}
PALETTES = {
"dark": {
"PRIMARY": "#071A17",
"DARK": "#0B211D",
"SECONDARY": "#244B3C",
"LIGHT": "#F4EAD4",
"ACCENT": "#C89A2B",
"BG_WINDOW": "#061916",
"BG_SIDEBAR": "#08221E",
"BG_HEADER": "#071B18",
"BG_STATUSBAR": "#061512",
"BG_MODAL_BACKDROP": "#020B09",
"SURFACE": "#0B2520",
"SURFACE_HOVER": "#12342C",
"SURFACE_ACTIVE": "#194535",
"SURFACE_SELECTED": "#173B2E",
"SURFACE_MUTED": "#091E1A",
"BORDER": "#36513B",
"BORDER_SUBTLE": "#203A2D",
"BORDER_ACCENT": "#B78525",
"BORDER_HOVER": "#537456",
"TEXT_PRIMARY": "#F8F0DC",
"TEXT_SECONDARY": "#D1C7AE",
"TEXT_MUTED": "#8FA395",
"TEXT_ON_ACCENT": "#102018",
"TEXT_ACCENT": "#E0B84E",
"TEXT_DISABLED": "#617367",
"ACCENT_HOVER": "#E0B84E",
"ACCENT_PRESSED": "#A9781E",
"ACCENT_DIM": "#3D3218",
"ACCENT_GLOW": "#D9AA37",
"DANGER_SURFACE": "#4A2020",
"DANGER_SURFACE_HOVER": "#692A2A",
"DANGER_BORDER": "#8A3B36",
"DANGER_TEXT": "#FFD8D2",
"STATUS_HEALTHY": "#72C943",
"STATUS_WARNING": "#E1A62B",
"STATUS_ERROR": "#E45C4F",
"STATUS_INFO": "#4C8DD8",
"STATUS_AUTH_REQUIRED": "#E1A62B",
"STATUS_DISABLED": "#708078",
"STATUS_FAILOVER": "#4C8DD8",
"STATUS_MAIN": "#C89A2B",
"STATUS_ORCHESTRATOR": "#E0B84E",
"PROVIDER_ANTIGRAVITY": "#74A9FF",
"PROVIDER_CODEX": "#46BE8A",
"PROVIDER_OPENCODE": "#F39A50",
"PROVIDER_CLAUDE": "#DF9C63",
"PROVIDER_GROK": "#6DA6F2",
"PROVIDER_GENERIC": "#A99DD8",
"SIDEBAR_TEXT": "#F8F0DC",
"SIDEBAR_MUTED": "#8FA395",
"SIDEBAR_HOVER": "#12342C",
"SIDEBAR_SELECTED": "#173B2E",
},
"hybrid": {
"PRIMARY": "#F7F2E8",
"DARK": "#0A2721",
"SECONDARY": "#DCE6D7",
"LIGHT": "#FCF8F0",
"ACCENT": "#B98118",
"BG_WINDOW": "#F5F0E7",
"BG_SIDEBAR": "#08251F",
"BG_HEADER": "#FBF8F1",
"BG_STATUSBAR": "#EFE8DB",
"BG_MODAL_BACKDROP": "#E8E0D3",
"SURFACE": "#FBF8F1",
"SURFACE_HOVER": "#EEE8DA",
"SURFACE_ACTIVE": "#E1EAD9",
"SURFACE_SELECTED": "#E4EBD8",
"SURFACE_MUTED": "#F1EBE0",
"BORDER": "#D8CCB8",
"BORDER_SUBTLE": "#E7DDCE",
"BORDER_ACCENT": "#BD8B2F",
"BORDER_HOVER": "#AFA188",
"TEXT_PRIMARY": "#17241D",
"TEXT_SECONDARY": "#4C584F",
"TEXT_MUTED": "#7B817B",
"TEXT_ON_ACCENT": "#FFFFFF",
"TEXT_ACCENT": "#9B6C13",
"TEXT_DISABLED": "#A9AAA4",
"ACCENT_HOVER": "#CF9C39",
"ACCENT_PRESSED": "#936514",
"ACCENT_DIM": "#F0E4C8",
"ACCENT_GLOW": "#C59432",
"DANGER_SURFACE": "#F8E2DE",
"DANGER_SURFACE_HOVER": "#F1CBC5",
"DANGER_BORDER": "#C86155",
"DANGER_TEXT": "#812E28",
"STATUS_HEALTHY": "#397B35",
"STATUS_WARNING": "#BE7B13",
"STATUS_ERROR": "#C6473D",
"STATUS_INFO": "#326DAD",
"STATUS_AUTH_REQUIRED": "#BE7B13",
"STATUS_DISABLED": "#969B94",
"STATUS_FAILOVER": "#326DAD",
"STATUS_MAIN": "#B98118",
"STATUS_ORCHESTRATOR": "#A87817",
"PROVIDER_ANTIGRAVITY": "#326DAD",
"PROVIDER_CODEX": "#247A50",
"PROVIDER_OPENCODE": "#B96324",
"PROVIDER_CLAUDE": "#A55C2C",
"PROVIDER_GROK": "#326DAD",
"PROVIDER_GENERIC": "#7365A5",
"SIDEBAR_TEXT": "#F8F0DC",
"SIDEBAR_MUTED": "#98A89D",
"SIDEBAR_HOVER": "#12342C",
"SIDEBAR_SELECTED": "#173B2E",
},
"light": {
"PRIMARY": "#FFF9EE",
"DARK": "#FFF8EC",
"SECONDARY": "#E9DFC9",
"LIGHT": "#FFFDF8",
"ACCENT": "#A96F12",
"BG_WINDOW": "#FFF9EF",
"BG_SIDEBAR": "#FBF0DE",
"BG_HEADER": "#FFFDF8",
"BG_STATUSBAR": "#F7EDDE",
"BG_MODAL_BACKDROP": "#EDE2D2",
"SURFACE": "#FFFDF8",
"SURFACE_HOVER": "#F4E9D9",
"SURFACE_ACTIVE": "#DFE8D7",
"SURFACE_SELECTED": "#E7EEDC",
"SURFACE_MUTED": "#F8F0E5",
"BORDER": "#DDCFB9",
"BORDER_SUBTLE": "#EDE2D2",
"BORDER_ACCENT": "#B57A1C",
"BORDER_HOVER": "#B8A78D",
"TEXT_PRIMARY": "#1E281F",
"TEXT_SECONDARY": "#505A51",
"TEXT_MUTED": "#7E837C",
"TEXT_ON_ACCENT": "#FFFFFF",
"TEXT_ACCENT": "#90600F",
"TEXT_DISABLED": "#ADAEA7",
"ACCENT_HOVER": "#C58B2D",
"ACCENT_PRESSED": "#835609",
"ACCENT_DIM": "#F2E3C4",
"ACCENT_GLOW": "#BA8428",
"DANGER_SURFACE": "#F9E5E0",
"DANGER_SURFACE_HOVER": "#F3D0C9",
"DANGER_BORDER": "#C85A4E",
"DANGER_TEXT": "#812E28",
"STATUS_HEALTHY": "#367B36",
"STATUS_WARNING": "#B97612",
"STATUS_ERROR": "#C6473D",
"STATUS_INFO": "#326DAD",
"STATUS_AUTH_REQUIRED": "#B97612",
"STATUS_DISABLED": "#999D96",
"STATUS_FAILOVER": "#326DAD",
"STATUS_MAIN": "#A96F12",
"STATUS_ORCHESTRATOR": "#9D6810",
"PROVIDER_ANTIGRAVITY": "#326DAD",
"PROVIDER_CODEX": "#247A50",
"PROVIDER_OPENCODE": "#B96324",
"PROVIDER_CLAUDE": "#A55C2C",
"PROVIDER_GROK": "#326DAD",
"PROVIDER_GENERIC": "#7365A5",
"SIDEBAR_TEXT": "#283129",
"SIDEBAR_MUTED": "#767D76",
"SIDEBAR_HOVER": "#F0E3D0",
"SIDEBAR_SELECTED": "#E6EEDB",
},
}
current_scheme = "dark"
@classmethod
def apply_scheme(cls, scheme: str) -> str:
"""Activate a complete palette and return its normalized key."""
normalized = scheme if scheme in cls.PALETTES else "dark"
for token, value in cls.PALETTES[normalized].items():
setattr(cls, token, value)
cls.current_scheme = normalized
cls.COLOR_POSITIVE = cls.STATUS_HEALTHY
cls.COLOR_CAUTION = cls.STATUS_WARNING
cls.COLOR_NEGATIVE = cls.STATUS_ERROR
cls.COLOR_NEUTRAL = cls.STATUS_DISABLED
cls.COLOR_BRAND = cls.ACCENT
return normalized
# ── Spacing Scale (px) ──
SPACE_XS = 4
SPACE_SM = 8
SPACE_MD = 12
SPACE_LG = 16
SPACE_XL = 24
SPACE_2XL = 32
# Layout aliases used by views and reusable components.
PAGE_PAD_X = SPACE_LG
PAGE_PAD_Y = SPACE_MD
SECTION_GAP = SPACE_MD
CARD_PAD_X = SPACE_MD
CARD_PAD_Y = SPACE_MD
INLINE_GAP = SPACE_SM
# ── Corner Radius Scale (px) ──
RADIUS_SM = 6
RADIUS_MD = 10
RADIUS_LG = 14
RADIUS_PILL = 999
# ── Control Heights & Dimensions ──
HEIGHT_BTN_SM = 30
HEIGHT_BTN_MD = 38
HEIGHT_BTN_LG = 44
HEIGHT_NAV_ITEM = 34
HEIGHT_HEADER = 56
HEIGHT_STATUSBAR = 30
WIDTH_SIDEBAR = 190
HEIGHT_INPUT = 36
HEIGHT_BADGE = 24
ACCOUNT_CARD_MIN_HEIGHT = 188
ACCOUNT_CARD_COLUMNS = 3
# ── Fonts ──
FONT_FAMILY_TITLE = "Cinzel"
FONT_FAMILY_UI = "Segoe UI"
FONT_FAMILY_MONO = "Consolas"
@classmethod
def font_title_hero(cls):
return (cls.FONT_FAMILY_TITLE, 24, "bold")
@classmethod
def font_title_page(cls):
return (cls.FONT_FAMILY_UI, 20, "bold")
@classmethod
def font_title(cls):
"""Compatibility alias for the normalized page title."""
return cls.font_title_page()
@classmethod
def font_title_section(cls):
return (cls.FONT_FAMILY_UI, 17, "bold")
@classmethod
def font_heading(cls):
return (cls.FONT_FAMILY_UI, 15, "bold")
@classmethod
def font_subheading(cls):
return (cls.FONT_FAMILY_UI, 14, "bold")
@classmethod
def font_body(cls):
return (cls.FONT_FAMILY_UI, 13)
@classmethod
def font_body_bold(cls):
return (cls.FONT_FAMILY_UI, 13, "bold")
@classmethod
def font_caption(cls):
return (cls.FONT_FAMILY_UI, 11)
@classmethod
def font_micro(cls):
return (cls.FONT_FAMILY_UI, 10)
@classmethod
def font_mono(cls):
return (cls.FONT_FAMILY_MONO, 13)
@classmethod
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")
@classmethod
def font_icon(cls):
return (cls.FONT_FAMILY_UI, 15)
@classmethod
def font_metric(cls):
return (cls.FONT_FAMILY_UI, 24, "bold")
Theme.apply_scheme("dark")

View file

@ -1,3 +0,0 @@
"""Hermes Hub UI Views Package."""
from __future__ import annotations

View file

@ -1,110 +0,0 @@
"""Hermes Hub — About View (О программе)."""
from __future__ import annotations
from typing import Any, Optional
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import (
HubButton,
HubCard,
HubSectionHeader,
)
from antigravity_provider.version import __version__
class AboutView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._build()
def _build(self):
header = HubSectionHeader(
self,
title="О программе",
subtitle="Информация о версии, архитектуре и назначении Hermes Hub",
)
header.pack(fill="x", padx=20, pady=(16, 12))
card = HubCard(self, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
card.pack(fill="both", expand=True, padx=20, pady=(0, 15))
# Center logo
logo_img = AssetManager.get().get_logo_image(size=(140, 140))
if logo_img:
logo_lbl = ctk.CTkLabel(card, image=logo_img, text="")
logo_lbl.pack(pady=(24, 10))
ctk.CTkLabel(
card,
text="HERMES HUB",
font=Theme.font_title_hero(),
text_color=Theme.TEXT_ACCENT,
).pack(pady=(0, 4))
ctk.CTkLabel(
card,
text="Multi-Agent & Multi-Provider Control Hub",
font=Theme.font_subheading(),
text_color=Theme.TEXT_PRIMARY,
).pack(pady=(0, 2))
ctk.CTkLabel(
card,
text=f"Версия {__version__} • Native Windows Application • Local-First",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
).pack(pady=(0, 16))
# Info Box
info_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
info_box.pack(fill="x", padx=32, pady=(0, 16))
desc_text = (
"Hermes Hub — нативный центр управления мультиагентной системой Hermes, "
"аккаунтами AI-провайдеров, моделями и отказоустойчивой маршрутизацией."
)
ctk.CTkLabel(
info_box,
text=desc_text,
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
wraplength=640,
justify="center",
).pack(padx=16, pady=14)
# Pillars
pillars_box = ctk.CTkFrame(card, fg_color="transparent")
pillars_box.pack(fill="x", padx=32, pady=(0, 16))
for i in range(3):
pillars_box.grid_columnconfigure(i, weight=1)
p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p1.grid(row=0, column=0, padx=4, sticky="nsew")
ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(8, 2)
)
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
pady=(0, 8)
)
p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p2.grid(row=0, column=1, padx=4, sticky="nsew")
ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(8, 2)
)
ctk.CTkLabel(
p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 8))
p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p3.grid(row=0, column=2, padx=4, sticky="nsew")
ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(8, 2)
)
ctk.CTkLabel(
p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 8))

View file

@ -1,266 +0,0 @@
"""Accounts and quota view driven exclusively by a supplied HubSnapshot."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Callable, Dict, Iterable, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import (
AccountCardWidget,
ActionButton,
EmptyState,
FilterButton,
SearchField,
SectionHeader,
)
from antigravity_provider.router.ui.theme import Theme
PROVIDER_LABELS = {
"antigravity": "Google Antigravity",
"openai-codex": "OpenAI Codex",
"opencode-go": "OpenCode Go",
"claude": "Claude",
"grok": "Grok",
}
class ProviderGroup(ctk.CTkFrame):
"""Fixed provider section with always-visible Cockpit-style cards."""
def __init__(self, master: Any, provider: str):
super().__init__(master, fg_color="transparent")
self.provider = provider
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.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_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")
self.body.pack(fill="x")
for column in range(Theme.ACCOUNT_CARD_COLUMNS):
self.body.grid_columnconfigure(column, weight=1)
def toggle_collapsed(self) -> None:
self.collapsed = False
self.body.pack(fill="x")
class AccountsView(ctk.CTkFrame):
"""Keyed accounts view; one account delta never reconstructs another card."""
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.on_action = on_action
self._snapshot: Optional[HubSnapshot] = None
self._cards: Dict[str, AccountCardWidget] = {}
self._groups: Dict[str, ProviderGroup] = {}
self._search = ""
self._provider_filter = "Все провайдеры"
self._health_filter = "Все состояния"
self._role_filter = "Все роли"
self.cards_created = 0
self.cards_destroyed = 0
self._build()
def _build(self) -> None:
header = SectionHeader(
self,
title="Аккаунты и квоты",
subtitle="Компактные карточки аккаунтов, реальные остатки и быстрые действия",
action_text="+ Добавить аккаунт",
action_cmd=lambda: self._emit("add_account", {}),
)
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
self.search = SearchField(toolbar, placeholder_text="Поиск по аккаунту, модели или роли…", width=300)
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
self.search.bind("<KeyRelease>", lambda _event: self._set_search(self.search.get()))
self.provider_filter = FilterButton(
toolbar,
values=["Все провайдеры", *PROVIDER_LABELS.values()],
command=self._set_provider_filter,
width=170,
)
self.provider_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
self.health_filter = FilterButton(
toolbar,
values=["Все состояния", "Работают", "Требуют входа", "Проблемные"],
command=self._set_health_filter,
width=150,
)
self.health_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
self.role_filter = FilterButton(
toolbar,
values=["Все роли", "orchestrator", "coder", "reviewer", "researcher", "tester", "general", "spare"],
command=self._set_role_filter,
width=135,
)
self.role_filter.pack(side="left")
ActionButton(
toolbar,
text="Обновить все",
variant="secondary",
command=lambda: self._emit("refresh_all", {}),
).pack(side="right")
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))
self.empty = EmptyState(
self.scroll,
title="Нет подходящих аккаунтов",
message="Измените фильтры или подключите новый аккаунт.",
action_text="Подключить аккаунт",
action_cmd=lambda: self._emit("add_account", {}),
)
def _emit(self, action: str, profile: Any) -> None:
if not self.on_action:
return
if isinstance(profile, dict):
payload = profile
else:
payload = {
"profile_id": profile.profile_id,
"provider": profile.provider,
"display_name": profile.display_name,
}
self.on_action(action, payload)
def _set_search(self, value: str) -> None:
self._search = value.strip().lower()
self._render_visibility()
def _set_provider_filter(self, value: str) -> None:
self._provider_filter = value
self._render_visibility()
def _set_health_filter(self, value: str) -> None:
self._health_filter = value
self._render_visibility()
def _set_role_filter(self, value: str) -> None:
self._role_filter = value
self._render_visibility()
def _matches(self, profile: Any) -> bool:
if self._provider_filter != "Все провайдеры":
if PROVIDER_LABELS.get(profile.provider, profile.provider) != self._provider_filter:
return False
if self._health_filter == "Работают" and profile.health_state != "healthy":
return False
if self._health_filter == "Требуют входа" and profile.auth_state not in {"AUTH_REQUIRED", "AUTH_EXPIRED"}:
return False
if self._health_filter == "Проблемные" and profile.health_state in {"healthy", "not_configured", "cold_spare"}:
return False
roles = list(getattr(profile, "assigned_roles", []) or [])
if self._role_filter != "Все роли" and not any(self._role_filter.lower() in role.lower() for role in roles):
return False
haystack = " ".join(
[
AccountCardWidget.resolve_identity(profile),
profile.display_name,
profile.provider_display_name,
*roles,
*(getattr(profile, "preferred_models", []) or []),
]
).lower()
return not self._search or self._search in haystack
def _profiles(self) -> Iterable[Any]:
if not self._snapshot:
return []
return (profile for profile in self._snapshot.all_profiles.values() if not profile.is_empty_slot)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self._snapshot = snapshot
live_ids = {profile.profile_id for profile in self._profiles()}
for profile_id in list(self._cards):
if profile_id not in live_ids:
self._cards.pop(profile_id).destroy()
self.cards_destroyed += 1
for profile in self._profiles():
group = self._groups.get(profile.provider)
if group is None:
group = ProviderGroup(self.scroll, profile.provider)
self._groups[profile.provider] = group
card = self._cards.get(profile.profile_id)
if card is None:
card = AccountCardWidget(
group.body,
profile.profile_id,
AccountCardWidget.resolve_identity(profile),
profile.provider_display_name,
compact=True,
on_action=self._emit,
)
self._cards[profile.profile_id] = card
self.cards_created += 1
card.update_account(profile, snapshot.quotas.get(profile.profile_id))
self._render_visibility()
def _render_visibility(self) -> None:
if not self._snapshot:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
return
grouped: Dict[str, list[Any]] = defaultdict(list)
for profile in self._profiles():
if self._matches(profile):
grouped[profile.provider].append(profile)
visible_total = 0
for provider, group in self._groups.items():
profiles = grouped.get(provider, [])
if not profiles:
group.pack_forget()
continue
group.pack(fill="x")
group.count.configure(text=f"{len(profiles)} аккаунт(а)")
for card in self._cards.values():
if card.profile_model and card.profile_model.provider == provider:
card.grid_remove()
for index, profile in enumerate(profiles):
self._cards[profile.profile_id].grid(
row=index // Theme.ACCOUNT_CARD_COLUMNS,
column=index % Theme.ACCOUNT_CARD_COLUMNS,
padx=Theme.SPACE_XS,
pady=Theme.SPACE_XS,
sticky="nsew",
)
visible_total += len(profiles)
if visible_total:
self.empty.pack_forget()
else:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
def render_stats(self) -> Dict[str, int]:
return {
"cards_created": self.cards_created,
"cards_destroyed": self.cards_destroyed,
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
}
def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool:
"""Keep account action feedback next to the originating controls."""
card = self._cards.get(profile_id)
if card is None:
return False
card.set_action_feedback(message, success)
return True

View file

@ -1,75 +0,0 @@
"""Empirical router telemetry and local host measurements."""
from __future__ import annotations
from typing import Any, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubMetricCard, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class AnalyticsView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
SectionHeader(
self,
title="Аналитика",
subtitle="Только собственные измерения Hermes Router",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
grid = ctk.CTkFrame(self, fg_color="transparent")
grid.pack(fill="x", padx=Theme.PAGE_PAD_X)
for column in range(3):
grid.grid_columnconfigure(column, weight=1)
definitions = (
("calls", "Вызовы", ""),
("latency", "P50 задержка", ""),
("errors", "Доля ошибок", ""),
("tokens", "Токены", ""),
("failovers", "Переключения", ""),
("cost", "Стоимость", "$"),
)
self.cards = {}
for index, (key, title, icon) in enumerate(definitions):
card = HubMetricCard(grid, title, "Н", "нет измерений", icon=icon)
card.grid(row=index // 3, column=index % 3, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
self.cards[key] = card
self.note = ctk.CTkLabel(
self,
text="Вызовы измерены Hermes Router; аппаратные показатели — локально через psutil. Внешний SLA не заявляется.",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
self.note.pack(anchor="w", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_LG)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
telemetry = dict(snapshot.metrics.get("telemetry") or {})
global_telemetry = dict(telemetry.get("global") or {})
if not telemetry.get("has_data"):
for card in self.cards.values():
card.val_label.configure(text="Н")
card.sub_label.configure(text="нет измерений")
return
values = {
"calls": str(global_telemetry.get("total_calls", "Н")),
"latency": f"{global_telemetry['latency_p50_ms']:.0f} мс"
if global_telemetry.get("latency_p50_ms") is not None
else "Н",
"errors": f"{global_telemetry['error_rate']:.1%}"
if global_telemetry.get("error_rate") is not None
else "Н",
"tokens": str(global_telemetry.get("total_tokens"))
if global_telemetry.get("total_tokens") is not None
else "Н",
"failovers": str(global_telemetry.get("failovers_count", 0)),
"cost": f"${global_telemetry['total_cost_usd']:.4f}"
if global_telemetry.get("total_cost_usd") is not None
else "Н",
}
for key, value in values.items():
self.cards[key].val_label.configure(text=value)
self.cards[key].sub_label.configure(text="own_measurement")

View file

@ -1,746 +0,0 @@
"""Approved Hermes Hub overview, visually aligned with the B5 reference."""
from __future__ import annotations
import tkinter as tk
from typing import Any, Callable, Dict, Iterable, Optional
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 HubButton, HubCard
from antigravity_provider.router.ui.theme import Theme
class _Sparkline(ctk.CTkFrame):
"""Tiny chart built only from values observed during this UI session."""
def __init__(self, master: Any, width: int = 58, height: int = 18):
super().__init__(master, width=width, height=height, fg_color="transparent")
self.pack_propagate(False)
self._width, self._height = width, height
self._values: list[float] = []
self.canvas = tk.Canvas(self, width=width, height=height, bg=Theme.SURFACE, highlightthickness=0, bd=0)
self.canvas.pack(fill="both", expand=True)
def update_value(self, value: Optional[float]) -> None:
if value is not None:
self._values.append(float(value))
self._values = self._values[-18:]
self.canvas.delete("all")
if not self._values:
self.canvas.create_line(3, self._height - 4, self._width - 3, self._height - 4, fill=Theme.BORDER_SUBTLE)
return
values = self._values if len(self._values) > 1 else [self._values[0], self._values[0]]
low, high = min(values), max(values)
span = max(high - low, max(abs(high), 1.0) * 0.08)
points: list[float] = []
for index, current in enumerate(values):
points.extend(
(
3 + index * (self._width - 6) / max(len(values) - 1, 1),
self._height - 3 - ((current - low) / span) * (self._height - 7),
)
)
self.canvas.create_line(*points, fill=Theme.STATUS_HEALTHY, width=1.4, smooth=True)
class _KpiCard(HubCard):
def __init__(self, master: Any, title: str, value: str, subtext: str):
super().__init__(master, corner_radius=Theme.RADIUS_SM, height=62)
self.grid_propagate(False)
self.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(self, text=title, font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY).grid(
row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(7, 0)
)
self.val_label = ctk.CTkLabel(self, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.val_label.grid(row=1, column=0, sticky="w", padx=10, pady=(0, 6))
self.sub_label = ctk.CTkLabel(self, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.sub_label.grid(row=1, column=1, sticky="e", padx=(2, 5), pady=(0, 6))
self.spark = _Sparkline(self, width=48, height=16)
self.spark.grid(row=1, column=2, sticky="e", padx=(0, 7), pady=(0, 5))
def set_metric(self, value: str, subtext: str, numeric: Optional[float] = None) -> None:
self.val_label.configure(text=value)
self.sub_label.configure(text=subtext)
self.spark.update_value(numeric)
class _EndpointCard(HubCard):
"""Compact provider/agent card with brand icon and a real quota bar."""
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_MD, height=76)
self.pack_propagate(False)
self.icon = ctk.CTkLabel(self, text="", width=38, font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT)
self.icon.pack(side="left", padx=(8, 5))
text = ctk.CTkFrame(self, fg_color="transparent")
text.pack(side="left", fill="both", expand=True, pady=3)
self.title = ctk.CTkLabel(text, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w")
self.subtitle = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
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=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.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._status = "unknown"
self._bind_click_tree(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._activate, add="+")
self.bind("<space>", self._activate, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_click_tree(self, widget: Any) -> None:
widget.configure(cursor="hand2")
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 _hover_on(self, _event: Any = None) -> None:
if self._click_action:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
self.configure(
fg_color=Theme.SURFACE,
border_color=Theme.BORDER_ACCENT if self._status == "healthy" else Theme.BORDER,
)
def update_card(
self,
provider_key: str,
title: str,
subtitle: str,
status_text: str,
quota_text: str,
quota_percent: Optional[float],
status: str,
) -> None:
self._status = status
image = AssetManager.get().get_provider_image(provider_key, size=(30, 30))
self.icon.configure(image=image, text="" if image else "")
self.icon.image = image
self.title.configure(text=title)
self.subtitle.configure(text=subtitle)
self.status.configure(text=f"{status_text}")
self.quota_label.configure(text=quota_text)
color = {
"healthy": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(status, Theme.STATUS_DISABLED)
self.status.configure(text_color=color)
self.configure(border_color=Theme.BORDER_ACCENT if status == "healthy" else Theme.BORDER)
self.progress.configure(progress_color=color)
self.progress.set(max(0.0, min(1.0, quota_percent / 100.0)) if quota_percent is not None else 0)
class _OrchestratorNode(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, width=174, height=150, fg_color="transparent")
self.pack_propagate(False)
self.circle = ctk.CTkFrame(
self,
width=96,
height=96,
corner_radius=48,
fg_color=Theme.BG_SIDEBAR if Theme.current_scheme != "light" else Theme.SURFACE,
border_width=2,
border_color=Theme.BORDER_ACCENT,
)
self.circle.pack(pady=(0, 3))
self.circle.pack_propagate(False)
logo = AssetManager.get().get_logo_image(size=(76, 76))
self.logo = ctk.CTkLabel(
self.circle, image=logo, text="H" if logo is None else "", text_color=Theme.TEXT_ACCENT
)
self.logo.image = logo
self.logo.place(relx=0.5, rely=0.5, anchor="center")
ctk.CTkLabel(
self, text="Главный оркестратор", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack()
self.subtitle = ctk.CTkLabel(self, text="Не назначен", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack()
self.status = ctk.CTkLabel(self, text="Н", font=Theme.font_micro(), text_color=Theme.STATUS_WARNING)
self.status.pack()
def update_node(self, subtitle: str, status_text: str, active: bool) -> None:
self.subtitle.configure(text=subtitle)
self.status.configure(
text=f"{status_text}", text_color=Theme.STATUS_HEALTHY if active else Theme.STATUS_WARNING
)
class _RouteDiagram(ctk.CTkFrame):
"""Responsive diagram with smooth connections behind native widgets."""
def __init__(self, master: Any):
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: list[Any] = []
self.agent_slots: list[Any] = []
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)
self.context.place(relx=0.51, rely=0.91, relwidth=0.25, anchor="center")
ctk.CTkLabel(
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_status.pack()
self._left_labels: list[str] = []
self._right_labels: list[str] = []
self._redraw_timer_id: Any = None
self.bind("<Configure>", self._schedule_redraw)
def _schedule_redraw(self, event: Any = None) -> None:
"""Свести поток <Configure> к одной перерисовке.
При перетаскивании окна Tk шлёт <Configure> непрерывно, и раньше каждое
событие вызывало полную перерисовку канвы: удаление и построение всех
связей, подписей и узлов. Очередь не успевала разгребаться, и окно
продолжало ползти ещё несколько секунд после того, как мышь отпущена.
Перерисовываем один раз, когда поток событий утих.
"""
if self._redraw_timer_id is not None:
try:
self.after_cancel(self._redraw_timer_id)
except Exception:
pass
self._redraw_timer_id = self.after(80, self._redraw_now)
def _redraw_now(self) -> None:
self._redraw_timer_id = None
try:
self._redraw()
except Exception:
pass
def sync_slots(self, provider_count: int, agent_count: int) -> None:
"""Grow/shrink endpoint slots to match the snapshot without hard caps."""
while len(self.provider_slots) < provider_count:
self.provider_slots.append(ctk.CTkFrame(self, height=76, fg_color="transparent"))
while len(self.agent_slots) < agent_count:
self.agent_slots.append(ctk.CTkFrame(self, height=68, fg_color="transparent"))
for index, slot in enumerate(self.provider_slots):
if index >= provider_count:
slot.place_forget()
continue
y = 0.02 + index * (0.78 / max(provider_count - 1, 1))
slot.place(relx=0.012, rely=y, relwidth=0.30)
for index, slot in enumerate(self.agent_slots):
if index >= agent_count:
slot.place_forget()
continue
y = 0.01 + index * (0.80 / max(agent_count - 1, 1))
slot.place(relx=0.71, rely=y, relwidth=0.278)
self._redraw()
def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = list(left)
self._right_labels = list(right)
self._redraw()
def _redraw(self, _event: Any = None) -> None:
width, height = max(self.winfo_width(), 600), max(self.winfo_height(), 300)
self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71
left_count = len(self._left_labels)
left_ys = [
height * (0.02 + index * (0.78 / max(left_count - 1, 1))) + 38 for index in range(left_count)
]
for index, y_pos in enumerate(left_ys):
self.canvas.create_line(
left_x,
y_pos,
left_x + 44,
y_pos,
center_x - 78,
center_y,
center_x - 48,
center_y,
fill=Theme.BORDER_ACCENT,
width=1.35,
smooth=True,
arrow=tk.LAST,
tags="route",
)
self.canvas.create_text(
left_x + 50,
y_pos - 8,
text=self._left_labels[index],
fill=Theme.TEXT_SECONDARY,
font=(Theme.FONT_FAMILY_UI, 8),
anchor="w",
tags="route",
)
right_count = len(self._right_labels)
right_ys = [
height * (0.01 + index * (0.80 / max(right_count - 1, 1))) + 34 for index in range(right_count)
]
for index, y_pos in enumerate(right_ys):
self.canvas.create_line(
center_x + 48,
center_y,
right_x - 34,
y_pos,
right_x,
y_pos,
fill=Theme.BORDER_ACCENT,
width=1.35,
smooth=True,
arrow=tk.LAST,
tags="route",
)
self.canvas.create_text(
right_x - 39,
y_pos - 8,
text=self._right_labels[index],
fill=Theme.TEXT_SECONDARY,
font=(Theme.FONT_FAMILY_UI, 8),
anchor="e",
tags="route",
)
self.canvas.create_line(
center_x,
center_y + 48,
center_x,
height * 0.83,
fill=Theme.BORDER_ACCENT,
width=1.2,
dash=(3, 3),
arrow=tk.LAST,
tags="route",
)
class _StatusRow(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, fg_color="transparent", height=24)
self.pack_propagate(False)
self.dot = ctk.CTkLabel(self, text="", width=12, font=Theme.font_micro())
self.dot.pack(side="left")
self.title = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left", padx=4)
self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.detail.pack(side="right")
def update_row(self, title: str, detail: str, status: str) -> None:
self.title.configure(text=title)
self.detail.configure(text=detail)
self.dot.configure(
text_color={
"healthy": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(status, Theme.STATUS_DISABLED)
)
class _SystemRow(ctk.CTkFrame):
def __init__(self, master: Any, title: str):
super().__init__(master, fg_color="transparent", height=25)
self.pack_propagate(False)
ctk.CTkLabel(
self, text=title, width=47, anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY
).pack(side="left")
self.value = ctk.CTkLabel(
self, text="Н", width=48, anchor="e", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
)
self.value.pack(side="left")
self.spark = _Sparkline(self, width=54, height=16)
self.spark.pack(side="right")
def update_metric(self, text: str, numeric: Optional[float]) -> None:
self.value.configure(text=text)
self.spark.update_value(numeric)
class _EventRow(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, fg_color="transparent", height=23)
self.pack_propagate(False)
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left")
self.dot = ctk.CTkLabel(self, width=18, text="", font=Theme.font_micro())
self.dot.pack(side="left")
self.category = ctk.CTkLabel(
self, width=112, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
)
self.category.pack(side="left", padx=(0, 6))
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.message.pack(side="left", fill="x", expand=True)
self.tag = ctk.CTkLabel(
self,
text="",
width=82,
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL,
)
self.tag.pack(side="right")
def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info"))
color = {
"success": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO)
category = str(getattr(event, "category", "system"))
self.time.configure(text=str(getattr(event, "timestamp", "Н")))
self.dot.configure(text_color=color)
self.category.configure(text=category.replace("_", " ").title())
self.message.configure(text=str(getattr(event, "message", "Событие без описания")))
self.tag.configure(text=category)
class DashboardView(ctk.CTkFrame):
"""Dense overview following the approved dashboard composition."""
def __init__(
self,
master: Any,
app_state: Optional[Dict[str, Any]] = None,
on_navigate: Optional[Callable] = None,
on_action: Optional[Callable] = None,
**kwargs,
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_navigate = on_navigate
self.on_action = on_action
self._provider_cards: Dict[str, _EndpointCard] = {}
self._agent_cards: Dict[str, _EndpointCard] = {}
self._event_rows: list[_EventRow] = []
self._build()
def _build(self) -> None:
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(8, 10))
self.snapshot_freshness = ctk.CTkLabel(
self.scroll,
text="● Система загружается",
font=Theme.font_micro(),
text_color=Theme.STATUS_HEALTHY,
anchor="w",
)
# 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(6):
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", "авторизованы")
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
self.host_metric = _KpiCard(self.metrics, "Нагрузка CPU", "Н", "нет измерения хоста")
for index, metric in enumerate(
(
self.quota_metric,
self.calls_metric,
self.agents_metric,
self.latency_metric,
self.failover_metric,
self.host_metric,
)
):
metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 5 else 3), sticky="nsew")
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x")
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
).pack(anchor="w", padx=10, pady=(8, 0))
self.route_diagram = _RouteDiagram(route_card)
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
self.events_card = HubCard(self.scroll)
self.events_card.pack(fill="x", pady=(8, 0))
events_header = ctk.CTkFrame(self.events_card, fg_color="transparent")
events_header.pack(fill="x", padx=10, pady=(7, 3))
ctk.CTkLabel(
events_header, text="Последние события", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(side="left")
ctk.CTkButton(
events_header,
text="Все события →",
width=82,
height=22,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_ACCENT,
font=Theme.font_micro(),
command=lambda: self.on_navigate("logs") if self.on_navigate else None,
).pack(side="right")
self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent")
self.events_body.pack(fill="x", padx=10, pady=(0, 7))
self.events_empty = ctk.CTkLabel(
self.events_body, text="События: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.events_empty.pack(anchor="w", pady=5)
@staticmethod
def _quota_measurement(snapshot: HubSnapshot, provider_id: Optional[str] = None) -> tuple[str, Optional[float]]:
profiles = snapshot.profiles_by_provider.get(provider_id, []) if provider_id else snapshot.all_profiles.values()
for profile in profiles:
quota = snapshot.quotas.get(profile.profile_id)
if not quota or getattr(quota, "is_estimated", True):
continue
for bucket in quota.buckets:
if bucket.remaining_percent is not None:
return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent)
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
reason = getattr(quota, "unavailable_reason", None) if quota else None
return reason or agent.active_quota_label or "Нет телеметрии: роль ещё не вызывалась", None
@staticmethod
def _sync_endpoint_cards(
slots: list[Any],
cache: Dict[str, _EndpointCard],
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
) -> None:
prepared = list(items)[: len(slots)]
live = {key for key, *_rest in prepared}
for key in list(cache):
if key not in live:
cache.pop(key).destroy()
for index, item in enumerate(prepared):
key, provider_key, title, subtitle, status_text, quota_text, quota_percent, status = item
card = cache.get(key)
if card is None:
card = _EndpointCard(slots[index])
card.pack(fill="both", expand=True)
cache[key] = card
card.update_card(provider_key, title, subtitle, status_text, quota_text, quota_percent, status)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self.snapshot_freshness.configure(
text=(
f"⚠ Данные устарели • snapshot #{snapshot.seq}"
if snapshot.is_stale
else f"● Система работает штатно • snapshot #{snapshot.seq}"
),
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 {})
role_telemetry = dict(telemetry.get("by_role") or {})
has_telemetry = bool(telemetry.get("has_data"))
total_calls = global_telemetry.get("total_calls") if has_telemetry else None
latency = global_telemetry.get("latency_p50_ms") if has_telemetry else None
failovers = global_telemetry.get("failovers_count") if has_telemetry else None
active_calls = snapshot.metrics.get("active_calls_total")
host = dict(snapshot.metrics.get("host") or {})
cpu = host.get("cpu_percent") if host.get("has_data") else None
quota_text, quota_percent = self._quota_measurement(snapshot)
self.quota_metric.set_metric(
quota_text, "реальная корзина" if quota_percent is not None else "нет измерения", quota_percent
)
self.calls_metric.set_metric(
str(total_calls) if total_calls is not None else "Н",
f"активно: {active_calls}"
if isinstance(active_calls, int)
else "нет телеметрии: запросы ещё не выполнялись",
float(total_calls) if total_calls is not None else None,
)
self.agents_metric.set_metric(
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 "Н",
"P50" if latency is not None else "нет телеметрии: запросы ещё не выполнялись",
float(latency) if latency is not None else None,
)
self.failover_metric.set_metric(
str(failovers) if failovers is not None else "Н",
"failover" if failovers is not None else "нет телеметрии переключений",
float(failovers) if failovers is not None else None,
)
self.host_metric.set_metric(
f"{cpu:.0f}%" if cpu is not None else "Н",
"host_measurement" if cpu is not None else "psutil не вернул измерение",
float(cpu) if cpu is not None else None,
)
providers = list(snapshot.providers)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator]
self.route_diagram.sync_slots(len(providers), len(agents))
self._sync_endpoint_cards(
self.route_diagram.provider_slots,
self._provider_cards,
(
(
provider.provider_id,
provider.provider_id,
provider.provider_name,
(
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),
"healthy" if provider.online_count else "warning",
)
for provider in providers
),
)
orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None)
if orchestrator:
self.route_diagram.orchestrator.update_node(
f"{orchestrator.provider_display_name}{orchestrator.model}",
"Онлайн" if orchestrator.is_active else orchestrator.status_label_ru,
orchestrator.is_active,
)
else:
self.route_diagram.orchestrator.update_node("Аккаунт не подключён", "Роль не настроена", False)
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_quota_text,
agent_quota_percent,
"healthy" if agent.is_active else "warning",
)
)
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 "нет телеметрии")
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 "нет вызовов")
self.route_diagram.set_labels(left_labels, right_labels)
def update_events(self, events: Iterable[Any]) -> None:
items = list(events)[:5]
while len(self._event_rows) < len(items):
row = _EventRow(self.events_body)
row.pack(fill="x", pady=1)
self._event_rows.append(row)
for index, row in enumerate(self._event_rows):
if index < len(items):
row.update_event(items[index])
row.pack(fill="x", pady=1)
else:
row.pack_forget()
if items:
self.events_empty.pack_forget()
else:
self.events_empty.pack(anchor="w", pady=5)

View file

@ -1,89 +0,0 @@
"""Keyed system-health view using only the supplied snapshot."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubCard, SectionHeader, StatusBadge
from antigravity_provider.router.ui.theme import Theme
class HealthProfileRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.identity.grid(row=0, column=0, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
self.models = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.models.grid(row=0, column=1, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
self.status = StatusBadge(self, "not_tested")
self.status.grid(row=0, column=2, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="e")
self.grid_columnconfigure(0, weight=2)
self.grid_columnconfigure(1, weight=3)
self.grid_columnconfigure(2, weight=1)
def update_profile(self, profile: Any) -> None:
identity = profile.email or profile.account_identity or profile.profile_id
self.identity.configure(text=f"{identity}\n{profile.profile_id}")
families = (
", ".join(f"{family}: {state.status_label_ru}" for family, state in profile.model_states.items())
or "Семейства моделей: Н"
)
self.models.configure(text=families)
self.status.set_status(profile.health_state, profile.health_label_ru)
class HealthView(ctk.CTkFrame):
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_refresh: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._rows: Dict[str, HealthProfileRow] = {}
SectionHeader(
self,
title="Состояние системы",
subtitle="Авторизация, локальное здоровье и наблюдаемые состояния моделей",
action_text="Обновить аудит",
action_cmd=on_refresh,
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
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))
self.banner = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK)
self.banner.pack(fill="x", pady=(0, Theme.SECTION_GAP))
self.title = ctk.CTkLabel(
self.banner, text="Ожидание snapshot", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT
)
self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.summary = ctk.CTkLabel(self.banner, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY)
self.summary.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
self.rows = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.rows.pack(fill="x")
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
readiness = snapshot.readiness
self.title.configure(text=readiness.title_ru)
warnings = "".join(readiness.warnings) if readiness.warnings else "Предупреждений нет"
self.summary.configure(
text=(
f"{readiness.summary_ru}\n"
f"Аккаунты {readiness.accounts_connected_count}/{readiness.total_accounts}"
f"Роли {readiness.roles_ready_count}/{readiness.total_roles}{warnings}"
)
)
live = set(snapshot.all_profiles)
for profile_id in list(self._rows):
if profile_id not in live:
self._rows.pop(profile_id).destroy()
ordered = sorted(snapshot.all_profiles.values(), key=lambda profile: (profile.provider, profile.display_name))
for index, profile in enumerate(ordered):
row = self._rows.get(profile.profile_id)
if row is None:
row = HealthProfileRow(self.rows)
self._rows[profile.profile_id] = row
row.update_profile(profile)
row.grid(row=index, column=0, sticky="ew", pady=Theme.SPACE_XS)
self.rows.grid_columnconfigure(0, weight=1)

View file

@ -1,127 +0,0 @@
"""Keyed event timeline populated by the application action layer."""
from __future__ import annotations
from typing import Any, Iterable, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import FilterButton, HubCard, SearchField, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class _LogRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
self.time = ctk.CTkLabel(self, text="", width=70, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left", padx=(Theme.CARD_PAD_X, 0), pady=Theme.SPACE_SM)
self.dot = ctk.CTkLabel(self, text="", width=18, font=Theme.font_micro())
self.dot.pack(side="left")
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_SM)
self.category = ctk.CTkLabel(
self,
text="",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL,
)
self.category.pack(side="right", padx=Theme.CARD_PAD_X)
def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info"))
color = {
"success": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO)
self.time.configure(text=str(getattr(event, "timestamp", "Н")))
self.dot.configure(text_color=color)
self.message.configure(text=str(getattr(event, "message", "Н")))
self.category.configure(text=f" {getattr(event, 'category', 'system')} ")
class LogsView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._events: list[Any] = []
self._rows: list[_LogRow] = []
self._query = ""
self._level = "Все уровни"
SectionHeader(
self,
title="Журнал событий",
subtitle="Реальные события Hermes Hub с уровнями и категориями",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
self.search = SearchField(toolbar, placeholder_text="Поиск по журналу…", width=320)
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
self.search.bind("<KeyRelease>", lambda _event: self._set_query(self.search.get()))
self.level = FilterButton(
toolbar,
values=["Все уровни", "Информация", "Успех", "Предупреждение", "Ошибка"],
command=self._set_level,
width=170,
)
self.level.pack(side="left")
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))
self.empty = ctk.CTkLabel(
self.scroll,
text="События: Н",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
def _set_query(self, value: str) -> None:
self._query = value.strip().lower()
self._render()
def _set_level(self, value: str) -> None:
self._level = value
self._render()
def _filtered(self) -> list[Any]:
levels = {
"Информация": "info",
"Успех": "success",
"Предупреждение": "warning",
"Ошибка": "error",
}
expected = levels.get(self._level)
result = []
for event in self._events:
if expected and str(getattr(event, "level", "info")) != expected:
continue
haystack = f"{getattr(event, 'category', '')} {getattr(event, 'message', '')}".lower()
if self._query and self._query not in haystack:
continue
result.append(event)
return result
def _render(self) -> None:
events = self._filtered()
while len(self._rows) < len(events):
row = _LogRow(self.scroll)
self._rows.append(row)
for index, row in enumerate(self._rows):
if index < len(events):
row.update_event(events[index])
row.pack(fill="x", pady=Theme.SPACE_XS)
else:
row.pack_forget()
if events:
self.empty.pack_forget()
else:
self.empty.pack(anchor="w", pady=Theme.SPACE_XL)
def update_events(self, events: Iterable[Any]) -> None:
self._events = list(events)
self._render()
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return

View file

@ -1,96 +0,0 @@
"""Provider summaries updated by stable provider id."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubCard, SectionHeader
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)
self.updated.pack(side="right")
self.stats = ctk.CTkLabel(self, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY)
self.stats.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.models = ctk.CTkLabel(
self,
text="",
font=Theme.font_mono_sm(),
text_color=Theme.TEXT_PRIMARY,
wraplength=900,
justify="left",
)
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(
text=(
f"Онлайн {summary.online_count}/{summary.connected_count}"
f"требуют входа {summary.auth_required_count}"
f"квота исчерпана {summary.quota_exhausted_count}"
f"холодный резерв {summary.cold_spare_count}"
)
)
self.models.configure(
text="Модели: "
+ (
"".join(summary.discovered_models)
if summary.discovered_models
else "Н/Д — список моделей ещё не получен"
)
)
class ProvidersView(ctk.CTkFrame):
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.on_action = on_action
self._cards: Dict[str, ProviderCard] = {}
SectionHeader(
self,
title="Провайдеры и модели",
subtitle="Локальная доступность адаптеров и реально обнаруженные модели",
action_text="Обновить",
action_cmd=lambda: self.on_action and self.on_action("refresh_data", {}),
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
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 update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
live = {summary.provider_id for summary in snapshot.providers}
for provider_id in list(self._cards):
if provider_id not in live:
self._cards.pop(provider_id).destroy()
for summary in snapshot.providers:
card = self._cards.get(summary.provider_id)
if card is None:
card = ProviderCard(self.scroll)
card.pack(fill="x", pady=Theme.SPACE_XS)
self._cards[summary.provider_id] = card
card.update_provider(summary)

View file

@ -1,83 +0,0 @@
"""Truthful multi-bucket quota overview driven by HubSnapshot."""
from __future__ import annotations
from typing import Any, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import EmptyState, HubCard, QuotaBar, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class _QuotaRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM)
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.title = ctk.CTkLabel(header, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.source = ctk.CTkLabel(header, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.source.pack(side="right")
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.bar = QuotaBar(self, label="Остаток")
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
def update_bucket(self, profile: Any, quota: Any, bucket: Any) -> None:
remaining = getattr(bucket, "remaining_percent", None)
ratio = float(remaining) / 100.0 if remaining is not None else None
detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н"
reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None
estimated = bool(getattr(quota, "is_estimated", True))
self.title.configure(text=f"{bucket.display_name}{profile.provider_display_name}")
self.identity.configure(text=profile.account_identity or profile.display_name)
self.source.configure(text="оценка" if estimated else str(getattr(quota, "source", "измерено")))
self.bar.set_value(ratio, detail)
reason = getattr(quota, "unavailable_reason", None)
self.reset.configure(text=reason or reset or "Сброс: Н")
class QuotasView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._rows: Dict[str, _QuotaRow] = {}
SectionHeader(
self,
title="Квоты и лимиты",
subtitle="Независимые корзины провайдеров; отсутствие измерения не считается нулём",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
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))
self.empty = EmptyState(
self.scroll,
title="Квоты: Н",
message="Провайдеры ещё не опубликовали ни одной квотной корзины.",
)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
incoming: list[tuple[str, Any, Any, Any]] = []
for profile in snapshot.all_profiles.values():
quota = snapshot.quotas.get(profile.profile_id)
for bucket in list(getattr(quota, "buckets", None) or []):
incoming.append((f"{profile.profile_id}:{bucket.id}", profile, quota, bucket))
live = {key for key, *_rest in incoming}
for key in list(self._rows):
if key not in live:
self._rows.pop(key).destroy()
for key, profile, quota, bucket in incoming:
row = self._rows.get(key)
if row is None:
row = _QuotaRow(self.scroll)
row.pack(fill="x", pady=Theme.SPACE_XS)
self._rows[key] = row
row.update_bucket(profile, quota, bucket)
if incoming:
self.empty.pack_forget()
else:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)

View file

@ -1,120 +0,0 @@
"""Keyed failover-chain view driven exclusively by RolePipeline objects."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import tkinter as tk
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import ActionButton, HubCard, RouteTargetWidget, SectionHeader
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.unified_health import RolePipeline
class RoutingRoleWidget(HubCard):
def __init__(self, master: Any, pipeline: RolePipeline, on_action: Optional[Callable] = None, **kwargs):
super().__init__(master, **kwargs)
self.pipeline = pipeline
self.on_action = on_action
self._nodes: Dict[str, RouteTargetWidget] = {}
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.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.meta = ctk.CTkLabel(top, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.meta.pack(side="left", padx=Theme.SPACE_SM)
ActionButton(
top,
text="Изменить цепочку →",
variant="secondary",
width=90,
command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}),
).pack(side="right")
self.chain = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.chain.pack(fill="x", padx=Theme.CARD_PAD_X)
self.footer = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.footer.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
self.update_from_pipeline(pipeline)
def update_from_pipeline(self, pipeline: RolePipeline) -> None:
self.pipeline = pipeline
self.title.configure(text=pipeline.role_name_ru)
affinity = "session affinity" if pipeline.session_affinity else "без affinity"
self.meta.configure(text=f"{pipeline.default_model}{affinity}")
live = {node.profile_id for node in pipeline.nodes}
for profile_id in list(self._nodes):
if profile_id not in live:
self._nodes.pop(profile_id).destroy()
for index, node in enumerate(pipeline.nodes):
rank = "Основной" if index == 0 else f"Резерв {index}"
identity = f"{node.account_identity}" if node.account_identity else ""
subtitle = f"{node.provider}{node.model}{identity}"
widget = self._nodes.get(node.profile_id)
if widget is None:
widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle)
self._nodes[node.profile_id] = widget
widget.update_target(
rank,
node.display_name,
subtitle,
"active" if node.is_active else node.status,
node.quota_status,
node.failover_reason,
)
widget.grid(row=0, column=index, padx=Theme.SPACE_XS, pady=Theme.SPACE_SM, sticky="nsew")
self.chain.grid_columnconfigure(index, weight=1)
active = next((node for node in pipeline.nodes if node.is_active), None)
reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason]
self.footer.configure(
text=(
f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'переключений ещё не было'}"
if active
else "Активный узел: Н/Д — аккаунт не подключён либо все профили недоступны"
)
)
class RoutingView(ctk.CTkFrame):
def __init__(
self, master: Any, routing_data: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action
self._role_widgets: Dict[str, RoutingRoleWidget] = {}
SectionHeader(
self,
title="Маршрутизация",
subtitle="Основной → резерв 1 → резерв 2 → резерв 3; без выдуманных причин failover",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
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
live_roles = set(snapshot.routing)
for role_id in list(self._role_widgets):
if role_id not in live_roles:
self._role_widgets.pop(role_id).destroy()
for role_id, pipeline in snapshot.routing.items():
widget = self._role_widgets.get(role_id)
if widget is None:
widget = RoutingRoleWidget(self.scroll, pipeline, self.on_action)
widget.pack(fill="x", pady=Theme.SPACE_XS)
self._role_widgets[role_id] = widget
widget.update_from_pipeline(pipeline)

View file

@ -1,122 +0,0 @@
"""Presentation-only settings screen; persistence is delegated to the app action layer."""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.ui.components import ActionButton, HubCard, SectionHeader
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.version import CHANNEL, __version__
class SettingsView(ctk.CTkFrame):
def __init__(
self,
master: Any,
on_action: Optional[Callable] = None,
theme_name: str = "dark",
**kwargs,
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action
SectionHeader(
self,
title="Настройки",
subtitle="Маршрутизация, мониторинг и обновления",
action_text="Сохранить",
action_cmd=self._save,
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
appearance = self._section(scroll, "Оформление")
theme_row = ctk.CTkFrame(appearance, fg_color="transparent")
theme_row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ctk.CTkLabel(
theme_row,
text="Цветовая схема",
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left")
self.theme = ctk.CTkOptionMenu(
theme_row,
values=list(Theme.SCHEME_LABELS.values()),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
text_color=Theme.TEXT_PRIMARY,
)
self.theme.set(Theme.SCHEME_LABELS.get(theme_name, Theme.SCHEME_LABELS["dark"]))
self.theme.pack(side="right")
routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
self.affinity = self._switch(routing, "Сессионная привязка", True)
self.failover = self._switch(routing, "Автоматический failover", True)
self.same_account = self._switch(routing, "Сначала менять модель на том же аккаунте", True)
refresh = self._section(scroll, "Мониторинг")
row = ctk.CTkFrame(refresh, fg_color="transparent")
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ctk.CTkLabel(row, text="Интервал обновления квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(
side="left"
)
self.interval = ctk.CTkOptionMenu(
row,
values=["Выкл", "1 мин", "5 мин", "10 мин", "30 мин"],
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
)
self.interval.set("5 мин")
self.interval.pack(side="right")
updates = self._section(scroll, "Обновления")
self.update_status = ctk.CTkLabel(
updates,
text=f"Версия {__version__} • канал {CHANNEL} • состояние обновления: Н",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
)
self.update_status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ActionButton(
updates,
text="Проверить обновления",
variant="secondary",
command=lambda: self.on_action and self.on_action("check_updates", {}),
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
@staticmethod
def _section(master: Any, title: str) -> HubCard:
card = HubCard(master)
card.pack(fill="x", pady=Theme.SPACE_XS)
ctk.CTkLabel(card, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)
)
return card
@staticmethod
def _switch(master: Any, label: str, selected: bool) -> ctk.CTkSwitch:
row = ctk.CTkFrame(master, fg_color="transparent")
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS)
ctk.CTkLabel(row, text=label, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
switch = ctk.CTkSwitch(row, text="", progress_color=Theme.ACCENT)
switch.pack(side="right")
if selected:
switch.select()
return switch
def _save(self) -> None:
if not self.on_action:
return
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
theme_key = next(
(key for key, label in Theme.SCHEME_LABELS.items() if label == self.theme.get()),
"dark",
)
self.on_action(
"save_settings",
{
"session_affinity": bool(self.affinity.get()),
"auto_failover": bool(self.failover.get()),
"prefer_same_account_model_fallback": bool(self.same_account.get()),
"quota_refresh_interval_label": self.interval.get(),
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
"theme": theme_key,
},
)

View file

@ -1,996 +0,0 @@
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
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
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import (
HubButton,
HubCard,
HubMetricCard,
HubSectionHeader,
HubStatusBadge,
)
from antigravity_provider.router.unified_health import (
AgentViewModel,
STATUS_HEALTHY,
STATUS_QUOTA_LOW,
STATUS_QUOTA_EXHAUSTED,
STATUS_AUTH_REQUIRED,
STATUS_NOT_CONFIGURED,
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):
"""Reusable agent card that updates in-place without rebuilding widgets."""
def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs):
super().__init__(master=master, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs)
self.on_action = on_action
self.agent_data: Optional[AgentViewModel] = None
# ── Line 1: Role Title + Badges + Status Dot ──
self.line1 = ctk.CTkFrame(self, fg_color="transparent")
self.line1.pack(fill="x", padx=14, pady=(12, 2))
self.role_lbl = ctk.CTkLabel(self.line1, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.role_lbl.pack(side="left")
self.orch_pill = ctk.CTkFrame(self.line1, fg_color=Theme.ACCENT_DIM, corner_radius=Theme.RADIUS_SM)
self.orch_pill_lbl = ctk.CTkLabel(
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
)
self.orch_pill_lbl.pack(padx=5, pady=1)
self.status_dot = ctk.CTkLabel(
self.line1, text="", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY
)
self.status_dot.pack(side="right")
# ── Line 2: Internal ID + Model ──
self.line2 = ctk.CTkFrame(self, fg_color="transparent")
self.line2.pack(fill="x", padx=14, pady=(0, 4))
self.id_model_lbl = ctk.CTkLabel(self.line2, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.id_model_lbl.pack(anchor="w")
# ── Line 3: Provider with Real Logo ──
self.line3 = ctk.CTkFrame(self, fg_color="transparent")
self.line3.pack(fill="x", padx=14, pady=(2, 2))
self.prov_icon = ctk.CTkLabel(self.line3, text="")
self.prov_icon.pack(side="left", padx=(0, 6))
self.prov_lbl = ctk.CTkLabel(self.line3, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.prov_lbl.pack(side="left")
# ── Line 4: Identity / Account ──
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
self.line4.pack(fill="x", padx=14, pady=(2, 4))
self.identity_lbl = ctk.CTkLabel(
self.line4, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY
)
self.identity_lbl.pack(anchor="w")
self.quota_lbl = ctk.CTkLabel(
self.line4, text="Квота: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0))
# ── Line 5: Role Tag Pills ──
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
self.line5.pack(fill="x", padx=14, pady=(4, 6))
self.pill1 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.pill1.pack(side="left", padx=(0, 6))
self.pill1_lbl = ctk.CTkLabel(self.pill1, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.pill1_lbl.pack(padx=6, pady=2)
self.pill2 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.pill2.pack(side="left", padx=(0, 6))
self.pill2_lbl = ctk.CTkLabel(self.pill2, text="Primary", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.pill2_lbl.pack(padx=6, pady=2)
# ── Line 6: Status & Menu Action ──
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
self.line6.pack(fill="x", padx=14, pady=(4, 12))
self.status_str_lbl = ctk.CTkLabel(
self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY
)
self.status_str_lbl.pack(side="left")
self.menu_btn = ctk.CTkButton(
self.line6,
text="",
width=28,
height=24,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_SECONDARY,
font=("Segoe UI", 12, "bold"),
command=self._open_menu,
)
self.menu_btn.pack(side="right")
self._bind_settings_click(self)
try:
self._canvas.configure(takefocus=1)
except (AttributeError, tk.TclError):
pass
self.bind("<Return>", self._open_settings, add="+")
self.bind("<space>", self._open_settings, add="+")
self.bind("<Enter>", self._hover_on, add="+")
self.bind("<Leave>", self._hover_off, add="+")
def _bind_settings_click(self, widget: Any) -> None:
if isinstance(widget, ctk.CTkButton):
return
widget.configure(cursor="hand2")
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 _hover_on(self, _event: Any = None) -> None:
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
def _hover_off(self, _event: Any = None) -> None:
is_orchestrator = bool(self.agent_data and self.agent_data.is_main_orchestrator)
self.configure(
fg_color=Theme.SURFACE,
border_color=Theme.BORDER_ACCENT if is_orchestrator else Theme.BORDER,
)
def update_agent(self, a: AgentViewModel):
self.agent_data = a
is_orch = a.is_main_orchestrator
self.configure(border_color=Theme.BORDER_ACCENT if is_orch else Theme.BORDER)
self.role_lbl.configure(text=a.role_name_ru)
if is_orch:
self.orch_pill.pack(side="left", padx=(8, 0))
else:
self.orch_pill.pack_forget()
# Dot color
dot_color = (
Theme.STATUS_HEALTHY
if a.status == STATUS_HEALTHY
else (
Theme.STATUS_WARNING
if "quota" in a.status or "auth" in a.status or "not_configured" in a.status
else Theme.STATUS_ERROR
)
)
self.status_dot.configure(text_color=dot_color)
# ID + Model
self.id_model_lbl.configure(text=f"{a.assigned_profile_id}{a.model}")
# Provider Icon + Label
p_img = AssetManager.get().get_provider_image(a.provider, size=(18, 18))
if p_img:
self.prov_icon.configure(image=p_img)
self.prov_icon.pack(side="left", padx=(0, 6))
else:
self.prov_icon.pack_forget()
prov = a.provider.lower()
if "antigravity" in prov:
self.prov_lbl.configure(text="Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY)
elif "codex" in prov:
self.prov_lbl.configure(text="OpenAI Codex", text_color=Theme.PROVIDER_CODEX)
elif "opencode" in prov:
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
elif "claude" in prov or "anthropic" in prov:
self.prov_lbl.configure(text="Claude", text_color=Theme.PROVIDER_CLAUDE)
elif "grok" in prov or "xai" in prov:
self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK)
else:
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н")
quota_color = (
Theme.STATUS_HEALTHY
if a.active_quota_status == "healthy"
else (
Theme.STATUS_WARNING
if a.active_quota_status == "warning"
else Theme.STATUS_ERROR
if a.active_quota_status == "exhausted"
else Theme.TEXT_MUTED
)
)
quota_label = a.active_quota_label or "Н/Д — провайдер не отдал лимиты"
session = f" • сессия {a.session_id}" if a.session_id else ""
self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color)
# Pills
self.pill1_lbl.configure(text=a.role_id)
self.pill2_lbl.configure(text=a.routing_position)
# Status text
cd_str = f" ({a.cooldown_remaining_sec}s)" if a.cooldown_remaining_sec > 0 else ""
self.status_str_lbl.configure(text=f"{a.status_label_ru.upper()}{cd_str}", text_color=dot_color)
def _open_menu(self):
if not self.agent_data:
return
a = self.agent_data
pid = a.assigned_profile_id or ""
popup = ctk.CTkToplevel(self.winfo_toplevel())
popup.title(f"Действия: {a.role_name_ru}")
popup.geometry("320x260")
popup.configure(fg_color=Theme.DARK)
popup.resizable(False, False)
popup.transient(self.winfo_toplevel())
popup.grab_set()
popup.update_idletasks()
px = self.winfo_toplevel().winfo_x() + 300
py = self.winfo_toplevel().winfo_y() + 200
popup.geometry(f"+{px}+{py}")
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
c.pack(fill="both", expand=True, padx=10, pady=10)
ctk.CTkLabel(c, text=a.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
ctk.CTkLabel(
c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 10))
def _do(action_name: str):
popup.destroy()
if self.on_action:
self.on_action(
action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name}
)
HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(
fill="x", padx=12, pady=2
)
HubButton(
c,
text="👑 Назначить главным оркестратором",
variant="secondary",
height=30,
command=lambda: _do("set_orchestrator"),
).pack(fill="x", padx=12, pady=2)
HubButton(
c,
text="★ Сделать основным аккаунтом Hermes",
variant="secondary",
height=30,
command=lambda: _do("set_main"),
).pack(fill="x", padx=12, pady=2)
HubButton(
c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all")
).pack(fill="x", padx=12, pady=(2, 0))
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.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._drag_moved = False
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._subscribe_runtime_events()
self._draw_graph(rebuild=True)
self.after(20, self._restore_viewport)
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()
@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,
)
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
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
)
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(
self.inspector,
text="Открыть маршрутизацию",
variant="primary",
command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}),
).pack(fill="x", padx=10, pady=10)
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_moved = False
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]
if abs(dx) + abs(dy) > 1.5:
self._drag_moved = True
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:
clicked_role = 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()
if not self._drag_moved and self.on_action:
pipeline = self._pipeline_for(clicked_role)
active_profile = pipeline.active_profile_id if pipeline else ""
active_node = next(
(item for item in list(getattr(pipeline, "nodes", []) or []) if item.profile_id == active_profile),
None,
)
self.on_action(
"agent_settings",
{
"role_id": clicked_role,
"profile_id": active_profile,
"provider": getattr(active_node, "provider", ""),
},
)
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,
)
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:
self.after(0, lambda: self._apply_runtime_event(name, data))
except Exception:
pass
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()
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"
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)
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()
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()
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"
def _auto_layout(self) -> None:
self.controller.auto_layout()
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _undo(self) -> None:
if self.controller.undo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _redo(self) -> None:
if self.controller.redo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
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)

View file

@ -1465,6 +1465,429 @@ function closeModal() {
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden'); if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
} }
// ── MODALS (Account Details, Model Choice, Routing, Wizard) ──
function openAccountDetailsModal(profileId, isRefresh = false) {
_openAccountModalProfile = profileId;
if (!currentSnapshot) return;
const profile = (currentSnapshot.all_profiles || {})[profileId];
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const qs = profile.quota_snapshot || (currentSnapshot.quotas || {})[profileId];
const buckets = (qs && qs.buckets) ? qs.buckets : [];
let modelBlockHtml = '';
if (discoveredModels.length > 0) {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<label style="display:block; font-weight:600; font-size:12px; margin-bottom:6px;">Предпочитаемая модель профиля:</label>
<div style="display:flex; gap:8px;">
<select id="modal-model-select" class="select-filter" style="flex:1;">
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" onclick="handleSaveProfileModel('${escapeHtml(profileId)}')">Сохранить</button>
</div>
</div>
`;
} else {
modelBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
</div>
`;
}
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:14px;">
<div style="font-size:14px; font-weight:700;">${escapeHtml(profile.account_identity || profile.email || profileId)}</div>
<div style="font-size:12px; color:var(--text-muted); margin-top:2px;">
Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
Тариф: <strong>${escapeHtml(profile.plan_code || profile.plan || 'Неизвестен')}</strong>
Статус: <strong class="text-healthy">${escapeHtml(profile.health_label_ru || 'Работает')}</strong>
</div>
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
Назначенные роли: <strong>${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')}</strong>
</div>
</div>
${modelBlockHtml}
<h3 style="font-size:13px; font-weight:700; margin-bottom:8px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Квоты и корзины провайдера
</h3>
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:16px;">
${buckets.map((b) => `
<div style="background:var(--surface-muted); padding:8px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div style="display:flex; justify-content:space-between; font-weight:600;">
<span>${escapeHtml(b.display_name)}</span>
<span>${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'}</span>
</div>
<div class="quota-bar-track" style="margin:4px 0;">
<div class="quota-bar-fill" style="width:${Math.max(0, Math.min(100, b.remaining_percent || 0))}%; background-color:${(b.remaining_percent || 0) < 20 ? 'var(--status-warning)' : 'var(--status-healthy)'};"></div>
</div>
<div style="font-size:10px; color:var(--text-muted);">
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
</div>
</div>
`).join('') || '<div class="empty-text">Данные о квотах отсутствуют (провайдер не отдал лимиты).</div>'}
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" onclick="handleTestProfile('${escapeHtml(profileId)}')">Тест подключения</button>
<button class="btn btn-secondary" onclick="handleSetMainAccount('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')">Сделать основным</button>
<button class="btn btn-secondary" onclick="handleDeleteCredentials('${escapeHtml(profileId)}')">Удалить ключ</button>
<button class="btn btn-primary" onclick="closeModal()">Закрыть</button>
`;
showModal();
}
async function handleSetMainAccount(providerId, profileId) {
showToast(`Назначение ${profileId} основным аккаунтом...`, 'info');
const res = await executeAction('set_main', { provider: providerId, profile_id: profileId });
if (res.ok) {
showToast(`Профиль ${profileId} назначен основным`, 'success');
closeModal();
fetchSnapshot();
} else {
showToast(res.message || 'Ошибка назначения профиля', 'error');
}
}
async function handleSaveProfileModel(profileId) {
const sel = document.getElementById('modal-model-select');
if (!sel) return;
const model = sel.value;
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
}
const res = await executeAction('set_model', { profile_id: profileId, model: model });
if (feedbackArea) {
if (res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
currentSnapshot.all_profiles[profileId].preferred_models = [model];
}
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения модели')}</div>`;
}
}
}
function openAgentModelModal(roleId, profileId) {
if (!currentSnapshot) return;
const profile = (currentSnapshot.all_profiles || {})[profileId];
if (!profile) return;
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:12px; font-size:12px; color:var(--text-muted);">
Профиль агента: <strong>${escapeHtml(profile.display_name)} (${profileId})</strong> Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
</div>
${discoveredModels.length > 0 ? `
<div style="margin-bottom:16px;">
<label style="display:block; font-weight:600; margin-bottom:6px;">Выберите модель из обнаруженного списка:</label>
<select id="role-model-select" class="select-filter" style="width:100%;">
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
</div>
` : `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')"> Запросить список моделей</button>
</div>
`}
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
${discoveredModels.length > 0 ? `<button class="btn btn-primary" onclick="handleSaveRoleModel('${escapeHtml(roleId)}', '${escapeHtml(profileId)}')">Сохранить модель</button>` : ''}
`;
showModal();
}
async function handleSaveRoleModel(roleId, profileId) {
const sel = document.getElementById('role-model-select');
if (!sel) return;
const model = sel.value;
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
}
const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId });
if (feedbackArea) {
if (res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
if (currentSnapshot) {
if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
currentSnapshot.all_profiles[profileId].preferred_models = [model];
}
if (currentSnapshot.routing && currentSnapshot.routing[roleId]) {
currentSnapshot.routing[roleId].default_model = model;
}
if (currentSnapshot.agents) {
const ag = currentSnapshot.agents.find((a) => a.role_id === roleId);
if (ag) ag.model = model;
}
}
setTimeout(() => {
closeModal();
renderCurrentView();
}, 700);
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения модели')}</div>`;
}
}
}
async function handleRefreshProviderModels(providerId, profileId = null) {
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
const res = await executeAction('refresh_models', { provider: providerId });
if (res.ok) {
showToast('Запрос обновления моделей отправлен', 'success');
if (profileId) {
setTimeout(() => openAccountDetailsModal(profileId), 500);
}
}
}
async function handleTestProfile(profileId) {
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
}
const res = await executeAction('test', { profile_id: profileId });
if (feedbackArea) {
if (res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Тест успешно пройден')}</div>`;
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Тест завершился с ошибкой')}</div>`;
}
}
}
// ── Add Account Wizard ──
function openAddAccountWizard() {
elements.modalTitle.textContent = 'Мастер подключения учетной записи';
showWizardStep1();
showModal();
}
function showWizardStep1() {
elements.modalBody.innerHTML = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 1 из 3: Выберите провайдера ИИ
</div>
<div style="display:grid; grid-template-columns:1fr; gap:8px;">
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('grok')">
<span style="font-size:18px; color:var(--prov-grok);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">Grok (xAI)</div>
<div style="font-size:11px; color:var(--text-muted);">Device Code OAuth (работает на сервере) или API Key</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('openai-codex')">
<span style="font-size:18px; color:var(--prov-codex);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">OpenAI Codex</div>
<div style="font-size:11px; color:var(--text-muted);">Device Code OAuth (работает на сервере) или API Key</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('opencode-go')">
<span style="font-size:18px; color:var(--prov-opencode);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">OpenCode Go</div>
<div style="font-size:11px; color:var(--text-muted);">API Key / Токен подписки</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('claude')">
<span style="font-size:18px; color:var(--prov-claude);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">Claude (Anthropic)</div>
<div style="font-size:11px; color:var(--text-muted);">API Key или OAuth (по ссылке или с кодом подтверждения)</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('antigravity')">
<span style="font-size:18px; color:var(--prov-antigravity);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">Google Antigravity</div>
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (по ссылке с авто-возвратом или ручной вставкой)</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('local')">
<span style="font-size:18px; color:var(--status-healthy, #22c55e);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">Локальная модель (Local LLM)</div>
<div style="font-size:11px; color:var(--text-muted);">llama.cpp / Ollama / vLLM (OpenAI-совместимый сервер)</div>
</div>
</button>
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
`;
}
function showWizardStep2(providerId) {
let bodyHtml = '';
if (providerId === 'grok' || providerId === 'openai-codex') {
const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex';
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Авторизация ${providerName} по коду устройства
</div>
<div id="device-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div style="color:var(--text-secondary);">Запрашиваем код у провайдера</div>
</div>
`;
setTimeout(() => startDeviceAuth(providerId), 0);
} else if (providerId === 'antigravity' || providerId === 'claude') {
const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude';
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Авторизация ${providerName}
</div>
<div style="margin-bottom:10px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
</div>
</div>
<div style="margin-bottom:10px;">
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
</div>
<div id="redirect-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div style="color:var(--text-secondary);">Выберите слот и нажмите «Получить ссылку».</div>
</div>
`;
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Настройка локального сервера (Local LLM)
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="http://127.0.0.1:8081/v1" value="http://127.0.0.1:8081/v1">
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key (опционально):</label>
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Оставьте пустым, если ключ не требуется">
</div>
`;
} else {
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Ввод API ключа ${providerId}
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key / Subscription Token:</label>
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="sk-...">
</div>
`;
}
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
${bodyHtml}
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="showWizardStep1()"> Назад</button>
<button class="btn btn-primary" onclick="proceedToWizardStep3('${providerId}')">Продолжить </button>
`;
}
function proceedToWizardStep3(providerId) {
const baseInput = document.getElementById('wiz-base-url-input');
if (baseInput) {
window._wiz_base_url = baseInput.value.trim();
}
const tokenInput = document.getElementById('wiz-token-input');
if (tokenInput) {
window._wiz_token = tokenInput.value.trim();
}
showWizardStep3(providerId);
}
function showWizardStep3(providerId) {
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 3 из 3: Назначение роли для нового аккаунта
</div>
<div style="margin-bottom:14px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
<select class="select-filter" style="width:100%;" id="wiz-target-role">
<option value="coder-primary">Кодер 1 (Primary Coder)</option>
<option value="coder-secondary">Кодер 2 (Secondary Coder)</option>
<option value="orchestrator">Оркестратор (Fallback Router)</option>
<option value="reviewer">Ревьюер кода (Reviewer)</option>
<option value="research">Исследователь (Researcher)</option>
<option value="fast">Быстрые задачи (Fast / Flash)</option>
<option value="spare">Резервный пул (Spare Pool)</option>
</select>
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="showWizardStep2('${providerId}')"> Назад</button>
<button class="btn btn-primary" onclick="finishAddAccount('${providerId}')"> Завершить подключение</button>
`;
}
async function finishAddAccount(providerId) {
const roleSelect = document.getElementById('wiz-target-role');
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение нового профиля в конфигурации...</div>';
}
const payload = {
provider: providerId,
target_role: targetRole,
};
if (window._wiz_base_url) {
payload.base_url = window._wiz_base_url;
}
if (window._wiz_token) {
payload.token = window._wiz_token;
}
const res = await executeAction('add_account', payload);
if (res && res.ok) {
showToast('Аккаунт успешно сохранен в конфигурации', 'success');
closeModal();
fetchSnapshot();
} else {
if (feedbackArea) {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Не удалось завершить подключение')}</div>`;
}
}
}
// ── TOAST NOTIFICATIONS ── // ── TOAST NOTIFICATIONS ──
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
if (!elements.toastContainer) return; if (!elements.toastContainer) return;

View file

@ -36,35 +36,6 @@ def isolate_hermes_environment(tmp_path, monkeypatch):
def pytest_configure(config): def pytest_configure(config):
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment") config.addinivalue_line("markers", "unit: mark test as a unit test")
config.addinivalue_line("markers", "integration: mark test as an integration test")
config.addinivalue_line("markers", "installer: mark test as installer test")
def pytest_collection_modifyitems(config, items):
"""Skip tests explicitly marked as needing the Tk graphical stack.
Selection is by the ``ui`` marker only. It used to also match any test whose
*name* contained "ui", "view" or "wizard", which silently skipped static
checks that never touch the toolkit and hid real failures for weeks.
Modules that import the GUI stack guard themselves with
``pytest.importorskip("customtkinter")`` at module scope; that guard is
enforced by tests/test_import_invariants.py.
"""
try:
import customtkinter # noqa: F401
except Exception:
skip_ui = pytest.mark.skip(reason="customtkinter is not installed in current environment")
for item in items:
if "ui" in item.keywords:
item.add_marker(skip_ui)
@pytest.fixture(scope="session")
def tk_root():
"""Shared Tkinter root for all UI tests to avoid Tcl resource exhaustion."""
try:
import customtkinter as ctk
root = ctk.CTk()
root.withdraw()
yield root
root.destroy()
except Exception as e:
pytest.skip(f"Tkinter could not be initialized: {e}")

View file

@ -36,11 +36,6 @@ from antigravity_provider.router.codex_oauth import (
get_codex_oauth_session, get_codex_oauth_session,
cancel_codex_oauth_session, cancel_codex_oauth_session,
) )
# Pulls customtkinter transitively; without this guard a headless run aborts
# collection of the entire session instead of skipping this module.
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui.components import enable_clipboard_shortcuts, HubEntry
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -277,23 +272,7 @@ def test_g_extract_jwt_identity():
assert sub2 == "auth0|openai-456" assert sub2 == "auth0|openai-456"
# ==============================================================================
# TEST H: HubEntry and enable_clipboard_shortcuts
# ==============================================================================
def test_h_enable_clipboard_shortcuts_binding():
"""Test that enable_clipboard_shortcuts attaches paste/copy/cut/select-all handlers without error."""
mock_entry = MagicMock()
mock_entry._entry = MagicMock()
mock_entry.clipboard_get.return_value = "pasted_text_123"
enable_clipboard_shortcuts(mock_entry)
# Check bind was called for multiple standard and Cyrillic keys
bound_events = [c[0][0] for c in mock_entry._entry.bind.call_args_list]
assert "<Control-v>" in bound_events
assert "<Control-a>" in bound_events
assert "<Shift-Insert>" in bound_events
assert "<Control-cyrillic_em>" in bound_events
# ============================================================================== # ==============================================================================

View file

@ -129,11 +129,10 @@ def test_adapter_no_browser_on_expired_token(clean_env, monkeypatch):
assert "Авторизация истекла" in str(exc_info.value) assert "Авторизация истекла" in str(exc_info.value)
@pytest.mark.ui @pytest.mark.unit
def test_do_test_profile_no_browser_on_expired_token(clean_env): def test_do_test_profile_no_browser_on_expired_token(clean_env):
"""P0-3: Verify do_test_profile in UI layer catches expired token without invoking adapter.""" """P0-3: Verify do_test_profile catches expired token without invoking adapter."""
pytest.importorskip("customtkinter") from antigravity_provider.router.action_handler import do_test_profile
from antigravity_provider.router.hermes_hub_app import do_test_profile
expired_auth = { expired_auth = {
"provider": "antigravity", "provider": "antigravity",

View file

@ -31,11 +31,8 @@ TESTS_DIR = Path(__file__).resolve().parent
# repository sources, so the suite never grades a different deployed version. # repository sources, so the suite never grades a different deployed version.
PACKAGE_ROOT = TESTS_DIR.parent / "src" / "antigravity_provider" PACKAGE_ROOT = TESTS_DIR.parent / "src" / "antigravity_provider"
# Third-party packages that may legitimately be absent (GUI / optional extras). # Third-party packages that may legitimately be absent (optional extras).
OPTIONAL_EXTERNAL_MODULES = { OPTIONAL_EXTERNAL_MODULES = {
"customtkinter",
"tkinter",
"PIL",
"psutil", "psutil",
"fastapi", "fastapi",
"uvicorn", "uvicorn",
@ -76,47 +73,39 @@ def test_module_is_importable(module_name: str) -> None:
@pytest.mark.unit @pytest.mark.unit
def test_gui_test_modules_guard_optional_ui_dependency() -> None: def test_zero_desktop_ui_imports_across_tests_and_src() -> None:
"""Test modules touching customtkinter must call pytest.importorskip. """Verify that neither src nor tests import deleted desktop UI modules, hermes_hub_app, or customtkinter."""
FORBIDDEN_PREFIXES = (
Without the guard a headless environment aborts collection of the whole
session ("Interrupted: 1 error during collection") instead of skipping the
affected module, which takes the release gate down with it.
"""
# Modules that pull the GUI toolkit in transitively when imported.
GUI_BEARING_PREFIXES = (
"customtkinter", "customtkinter",
"PIL",
"antigravity_provider.router.ui", "antigravity_provider.router.ui",
"antigravity_provider.router.hermes_hub_app", "antigravity_provider.router.hermes_hub_app",
) )
def _imports_gui(tree: ast.AST) -> bool: def _imports_forbidden(tree: ast.AST) -> bool:
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
if any(a.name.startswith(GUI_BEARING_PREFIXES) for a in node.names): if any(a.name.startswith(FORBIDDEN_PREFIXES) for a in node.names):
return True return True
elif isinstance(node, ast.ImportFrom): elif isinstance(node, ast.ImportFrom):
if (node.module or "").startswith(GUI_BEARING_PREFIXES): if (node.module or "").startswith(FORBIDDEN_PREFIXES):
return True return True
return False return False
offenders: list[str] = [] offenders: list[str] = []
for path in sorted(TESTS_DIR.glob("test_*.py")): for search_dir in (PACKAGE_ROOT, TESTS_DIR):
for path in sorted(search_dir.rglob("*.py")):
text = path.read_text(encoding="utf-8", errors="ignore") text = path.read_text(encoding="utf-8", errors="ignore")
try: try:
tree = ast.parse(text) tree = ast.parse(text)
except SyntaxError: except SyntaxError:
continue continue
# A mention in prose is not an import; only real imports need the guard. if _imports_forbidden(tree):
if not _imports_gui(tree): offenders.append(str(path.relative_to(TESTS_DIR.parent)))
continue
if "importorskip" not in text:
offenders.append(path.name)
assert not offenders, ( assert not offenders, (
"test modules import customtkinter without pytest.importorskip: " "Found forbidden desktop / customtkinter imports in: "
+ ", ".join(offenders) + ", ".join(offenders)
+ " — add pytest.importorskip('customtkinter') above the import"
) )

View file

@ -16,7 +16,7 @@ LAUNCHER_DIR = REPO_ROOT / "launcher"
def test_windows_csharp_launchers_and_setup_compile(): def test_windows_csharp_launchers_and_setup_compile():
"""Verify HermesHub.cs, HermesHubWeb.cs, and HermesHubSetup.cs compile without errors on Windows.""" """Verify HermesHubWeb.cs and HermesHubSetup.cs compile without errors on Windows."""
csc_path = r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe" csc_path = r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if not os.path.isfile(csc_path): if not os.path.isfile(csc_path):
csc_path = r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe" csc_path = r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"
@ -27,32 +27,22 @@ def test_windows_csharp_launchers_and_setup_compile():
temp_out = REPO_ROOT / "artifacts" / "test_compile" temp_out = REPO_ROOT / "artifacts" / "test_compile"
temp_out.mkdir(parents=True, exist_ok=True) temp_out.mkdir(parents=True, exist_ok=True)
# 1. Compile HermesHub.cs # 1. Compile HermesHubWeb.cs
hub_cs = LAUNCHER_DIR / "HermesHub.cs"
res1 = subprocess.run([
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHub.exe'}",
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", str(hub_cs)
], capture_output=True, text=True)
assert res1.returncode == 0, f"HermesHub.cs compilation failed: {res1.stdout}\n{res1.stderr}"
# 2. Compile HermesHubWeb.cs
hub_web_cs = LAUNCHER_DIR / "HermesHubWeb.cs" hub_web_cs = LAUNCHER_DIR / "HermesHubWeb.cs"
res2 = subprocess.run([ res1 = subprocess.run([
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubWeb.exe'}", csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubWeb.exe'}",
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", str(hub_web_cs) "/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", str(hub_web_cs)
], capture_output=True, text=True) ], capture_output=True, text=True)
assert res2.returncode == 0, f"HermesHubWeb.cs compilation failed: {res2.stdout}\n{res2.stderr}" assert res1.returncode == 0, f"HermesHubWeb.cs compilation failed: {res1.stdout}\n{res1.stderr}"
# 3. Compile HermesHubSetup.cs # 2. Compile HermesHubSetup.cs
setup_cs = INSTALLER_DIR / "HermesHubSetup.cs" setup_cs = INSTALLER_DIR / "HermesHubSetup.cs"
res3 = subprocess.run([ res2 = subprocess.run([
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubSetup.exe'}", csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubSetup.exe'}",
# Установщик несёт содержимое вшитым ресурсом и распаковывает его через
# ZipFile — сборка требует System.IO.Compression.FileSystem.
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", "/r:System.Windows.Forms.dll", "/r:System.Drawing.dll",
"/r:System.IO.Compression.FileSystem.dll", str(setup_cs) "/r:System.IO.Compression.FileSystem.dll", str(setup_cs)
], capture_output=True, text=True) ], capture_output=True, text=True)
assert res3.returncode == 0, f"HermesHubSetup.cs compilation failed: {res3.stdout}\n{res3.stderr}" assert res2.returncode == 0, f"HermesHubSetup.cs compilation failed: {res2.stdout}\n{res2.stderr}"
def test_windows_launcher_browser_search_and_health_check(): def test_windows_launcher_browser_search_and_health_check():
@ -67,14 +57,13 @@ def test_windows_launcher_browser_search_and_health_check():
assert "HermesHubWeb" in web_cs or "WebLauncher" in web_cs assert "HermesHubWeb" in web_cs or "WebLauncher" in web_cs
def test_windows_installer_creates_both_shortcuts(): def test_windows_installer_creates_single_web_shortcut():
"""Verify HermesHubSetup.cs creates both Web and Desktop shortcuts.""" """Verify HermesHubSetup.cs creates single Hermes Hub shortcut and cleans legacy ones."""
setup_cs = (INSTALLER_DIR / "HermesHubSetup.cs").read_text(encoding="utf-8") setup_cs = (INSTALLER_DIR / "HermesHubSetup.cs").read_text(encoding="utf-8")
assert "Hermes Hub (Web).lnk" in setup_cs assert "Hermes Hub.lnk" in setup_cs
assert "Hermes Hub (Desktop).lnk" in setup_cs assert "Hermes Hub (Desktop).lnk" in setup_cs # in legacy cleanup array
assert "HermesHubWeb.exe" in setup_cs assert "HermesHubWeb.exe" in setup_cs
assert "HermesHub.exe" in setup_cs
def test_linux_installer_script_structure(): def test_linux_installer_script_structure():

View file

@ -8,8 +8,6 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.router_config import ( from antigravity_provider.router.router_config import (
@ -18,7 +16,6 @@ from antigravity_provider.router.router_config import (
RouterProfileConfig, RouterProfileConfig,
save_router_config, save_router_config,
) )
from antigravity_provider.router.ui.model_catalog import CachedModels, refresh_models_async
@pytest.fixture @pytest.fixture
@ -138,8 +135,8 @@ def test_model_discovery_cache_retained_on_timeout_and_error(isolated_hub):
assert service.get_models("antigravity") == ["existing-gemini-model"] assert service.get_models("antigravity") == ["existing-gemini-model"]
def test_ui_model_catalog_refresh_models_async(isolated_hub): def test_model_discovery_refresh_models_async(isolated_hub):
"""model_catalog.refresh_models_async dispatches and completes via service.""" """ModelDiscoveryService.refresh_models_async dispatches and completes."""
service = ModelDiscoveryService.get() service = ModelDiscoveryService.get()
with service._cache_lock: with service._cache_lock:
service._cache["antigravity"] = { service._cache["antigravity"] = {
@ -147,16 +144,16 @@ def test_ui_model_catalog_refresh_models_async(isolated_hub):
"discovered_at": 1000.0, "discovered_at": 1000.0,
} }
completed: list[CachedModels] = [] completed: list[list[str]] = []
done_evt = threading.Event() done_evt = threading.Event()
def _on_done(cm: CachedModels) -> None: def _on_done(models: list[str] | None) -> None:
completed.append(cm) if models:
completed.append(models)
done_evt.set() done_evt.set()
with patch.object(service, "_probe_provider", return_value=["gemini-2.5-pro", "gemini-2.5-flash"]): with patch.object(service, "_probe_provider", return_value=["gemini-2.5-pro", "gemini-2.5-flash"]):
ok = refresh_models_async("antigravity", _on_done) service.refresh_models_async("antigravity", on_complete=_on_done)
assert ok is True
assert done_evt.wait(timeout=3.0) is True assert done_evt.wait(timeout=3.0) is True
assert len(completed) == 1 assert len(completed) == 1
assert "gemini-2.5-pro" in completed[0].models assert "gemini-2.5-pro" in completed[0]

View file

@ -163,71 +163,29 @@ def test_d_oauth_error_callback(tmp_path, monkeypatch):
@pytest.mark.unit @pytest.mark.unit
def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root): def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch):
"""TEST E: Repeated 'Открыть в браузере' does NOT change session, state, verifier, or URL.""" """TEST E: Starting profile OAuth preserves session, state, verifier, and URL invariants."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
root = ctk.CTkToplevel(tk_root) session_id, orig_url, port = start_profile_oauth("ag-orch-primary")
root.withdraw() session = get_oauth_session(session_id)
try: assert session is not None
wizard = AddAccountWizard(root) assert session.session_id == session_id
wizard.selected_provider = "grok" assert session.get_auth_url() == orig_url
wizard.target_slot = "grok-worker-1" assert session.port == port
wizard._show_step_2_auth() assert session.is_listening is True
orig_session_id = wizard.grok_session_id
orig_url = wizard.grok_url
session = get_grok_oauth_session(orig_session_id)
with patch("webbrowser.open") as mock_open:
wizard._open_grok_browser()
wizard._open_grok_browser()
wizard._open_grok_browser()
assert mock_open.call_count == 3
for call in mock_open.call_args_list:
assert call[0][0] == orig_url
assert wizard.grok_session_id == orig_session_id
assert wizard.grok_url == orig_url
wizard.destroy()
finally:
root.destroy()
@pytest.mark.unit @pytest.mark.unit
def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root): def test_f_copy_before_open_browser(tmp_path, monkeypatch):
"""TEST F: Copy button works immediately upon entering Step 2 without opening browser.""" """TEST F: OAuth URL is available immediately upon starting session without opening browser."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
root = ctk.CTkToplevel(tk_root) session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
root.withdraw() assert auth_url is not None
try: assert "accounts.google.com" in auth_url
wizard = AddAccountWizard(root) session = get_oauth_session(session_id)
wizard.selected_provider = "grok" assert session.get_auth_url() == auth_url
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
assert wizard.grok_url is not None
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
# Copy without opening browser
wizard._copy_grok_url()
clipboard_content = wizard.clipboard_get()
assert clipboard_content == wizard.grok_url
wizard.destroy()
finally:
root.destroy()
@pytest.mark.unit @pytest.mark.unit

View file

@ -53,19 +53,19 @@ def test_p0_1_installer_dependencies():
cs_content = setup_cs.read_text(encoding="utf-8") cs_content = setup_cs.read_text(encoding="utf-8")
assert "EnsurePythonDependencies" in cs_content, "Canonical installer must define dependency checking and installation" assert "EnsurePythonDependencies" in cs_content, "Canonical installer must define dependency checking and installation"
assert "customtkinter" in cs_content and "pillow" in cs_content.lower(), "Canonical installer must install customtkinter and Pillow" assert "fastapi" in cs_content and "uvicorn" in cs_content, "Canonical installer must install fastapi and uvicorn"
assert "customtkinter" not in cs_content, "Canonical installer must not contain legacy customtkinter"
assert "HERMES_HUB_IMPORT_OK" in cs_content, "Canonical installer must execute post-install import smoke test" assert "HERMES_HUB_IMPORT_OK" in cs_content, "Canonical installer must execute post-install import smoke test"
assert "assets" in cs_content, "Canonical installer must deploy branding and UI assets" assert "assets" in cs_content, "Canonical installer must deploy branding and UI assets"
# 2. Verify Runtime Dependency Availability # 2. Verify Runtime Dependency Availability
pytest.importorskip("customtkinter") import fastapi
import customtkinter import uvicorn
from PIL import Image
import psutil import psutil
import yaml import yaml
assert customtkinter is not None assert fastapi is not None
assert Image is not None assert uvicorn is not None
assert psutil is not None assert psutil is not None
assert yaml is not None assert yaml is not None

View file

@ -1,4 +1,4 @@
"""End-to-end verification for Grok and Claude connection, assignment, and testing.""" """End-to-end verification for Grok and Claude connection, assignment, and testing."""
from __future__ import annotations from __future__ import annotations
@ -8,8 +8,7 @@ import pytest
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router import action_handler from antigravity_provider.router import action_handler
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy 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.action_handler import do_test_profile
from antigravity_provider.router.hermes_hub_app import do_test_profile
@pytest.mark.unit @pytest.mark.unit
@ -37,15 +36,14 @@ def test_grok_wizard_definition_and_routing_flow():
roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])}, roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])},
) )
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ 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.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") ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
assert ok_def, f"Definition failed: {msg_def}" assert ok_def, f"Definition failed: {msg_def}"
assert "grok-worker-1" in config.profiles assert "grok-worker-1" in config.profiles
assert config.profiles["grok-worker-1"].provider == "grok" assert config.profiles["grok-worker-1"].provider == "grok"
ok_route, msg_route = ensure_profile_in_routing("grok-worker-1") ok_route, msg_route = AutoAssigner.assign_profile_to_role("grok-worker-1", "developer-1")
assert ok_route, f"Routing failed: {msg_route}" assert ok_route, f"Routing failed: {msg_route}"
assert "grok-worker-1" in config.roles["developer-1"].preferred_chain assert "grok-worker-1" in config.roles["developer-1"].preferred_chain
@ -58,15 +56,14 @@ def test_claude_wizard_definition_and_routing_flow():
roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])}, roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])},
) )
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ 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.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") ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
assert ok_def, f"Definition failed: {msg_def}" assert ok_def, f"Definition failed: {msg_def}"
assert "claude-orch" in config.profiles assert "claude-orch" in config.profiles
assert config.profiles["claude-orch"].provider == "claude" assert config.profiles["claude-orch"].provider == "claude"
ok_route, msg_route = ensure_profile_in_routing("claude-orch") ok_route, msg_route = AutoAssigner.assign_profile_to_role("claude-orch", "manager")
assert ok_route, f"Routing failed: {msg_route}" assert ok_route, f"Routing failed: {msg_route}"
assert "claude-orch" in config.roles["manager"].preferred_chain assert "claude-orch" in config.roles["manager"].preferred_chain

View file

@ -1,396 +0,0 @@
"""Acceptance coverage for UI state contract v1.1 fields."""
from __future__ import annotations
from dataclasses import replace
import time
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import AccountCardWidget, QuotaBucketWidget
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.routing_view import RoutingRoleWidget
from antigravity_provider.router.ui.views.team_view import AgentCardWidget
from antigravity_provider.router.unified_health import (
AgentViewModel,
PipelineNode,
ProfileViewModel,
ProviderSummary,
RolePipeline,
SystemReadiness,
)
@pytest.fixture(scope="module")
def ui_root(tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
yield root
root.destroy()
def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> ProfileViewModel:
return ProfileViewModel(
profile_id="account-1",
display_name="Primary",
account_identity="user@example.test",
provider="antigravity",
provider_display_name="Google Antigravity",
assigned_roles=["developer-1"],
primary_role="developer-1",
is_main_account=True,
is_main_orchestrator=False,
auth_state="AUTHENTICATED",
health_state="healthy",
health_label_ru="Работает",
model_states={},
cooldown_remaining_sec=0,
last_checked_at="12:00:00",
enabled=True,
is_cold_spare=False,
is_empty_slot=False,
plan_code=plan_code,
plan_source=plan_source,
)
def _readiness() -> SystemReadiness:
return SystemReadiness(
state="healthy",
title_ru="Система готова",
summary_ru="Все роли доступны",
roles_ready_count=1,
total_roles=1,
accounts_connected_count=1,
total_accounts=1,
providers_ready_count=1,
total_providers=1,
)
@pytest.mark.ui
def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None:
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
try:
card.pack()
card.update_account(_profile("PRO", "provider_api"))
ui_root.update_idletasks()
assert card.plan_badge.label.cget("text") == "Тариф PRO"
assert card.plan_badge.winfo_manager() == "pack"
card.update_account(_profile("PRO", "inferred"))
ui_root.update_idletasks()
assert card.plan_badge.label.cget("text") == "Тариф PRO • выведено"
assert card.plan_badge.label.cget("text_color") == Theme.TEXT_MUTED
card.update_account(_profile("UNKNOWN", "unknown"))
ui_root.update_idletasks()
assert card.plan_badge.winfo_manager() == ""
finally:
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")
try:
widget.pack()
widget.update_bucket("Claude 5h", None, unavailable_reason="Провайдер не вернул лимит")
ui_root.update_idletasks()
assert widget.bar.detail.cget("text") == "Н"
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEUTRAL
assert "Провайдер не вернул лимит" in widget.unavailable_reason.cget("text")
widget.update_bucket("Claude 5h", 0.0)
ui_root.update_idletasks()
assert widget.bar.detail.cget("text") == "0%"
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEGATIVE
assert widget.unavailable_reason.winfo_manager() == ""
finally:
widget.destroy()
@pytest.mark.ui
def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None:
agent = AgentViewModel(
role_id="developer-1",
role_name_ru="Кодер 1",
role_description_ru="Основной кодер",
assigned_profile_id="account-2",
assigned_display_name="Reserve",
provider="codex",
provider_display_name="OpenAI Codex",
model="gpt-5",
account_identity="reserve@example.test",
routing_position="Fallback 1",
status="healthy",
status_label_ru="Работает",
is_active=True,
is_main_orchestrator=False,
session_id="session-42",
active_quota_status="warning",
active_quota_label="Осталось 12%",
)
team_card = AgentCardWidget(ui_root)
pipeline = RolePipeline(
role_id="developer-1",
role_name_ru="Кодер 1",
default_model="gpt-5",
max_failover=2,
session_affinity=True,
active_profile_id="account-2",
nodes=[
PipelineNode(
profile_id="account-1",
display_name="Primary",
provider="Google Antigravity",
model="gemini-2.5-pro",
status="quota_exhausted",
status_label_ru="Исчерпан",
is_active=False,
account_identity="primary@example.test",
quota_status="exhausted",
failover_reason="Исчерпана квота (429)",
),
PipelineNode(
profile_id="account-2",
display_name="Reserve",
provider="OpenAI Codex",
model="gpt-5",
status="healthy",
status_label_ru="Работает",
is_active=True,
account_identity="reserve@example.test",
quota_status="warning",
),
],
)
route = RoutingRoleWidget(ui_root, pipeline)
try:
team_card.pack()
route.pack()
team_card.update_agent(agent)
route.update_from_pipeline(pipeline)
ui_root.update_idletasks()
assert "Осталось 12%" in team_card.quota_lbl.cget("text")
assert "session-42" in team_card.quota_lbl.cget("text")
assert "Исчерпана квота (429)" in route._nodes["account-1"].failover.cget("text")
assert route._nodes["account-2"].failover.cget("text") == ""
assert "Квота: исчерпана" == route._nodes["account-1"].quota.cget("text")
finally:
route.destroy()
team_card.destroy()
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
agent = AgentViewModel(
role_id="developer-1",
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(
generation=7,
seq=11,
timestamp=time.time() - 301,
profiles_by_provider={},
all_profiles={},
readiness=_readiness(),
agents=[],
providers=[],
routing={},
quotas={},
is_stale=True,
)
view = DashboardView(ui_root)
try:
view.pack()
view.update_data(snapshot)
ui_root.update_idletasks()
label = view.snapshot_freshness.cget("text")
assert "#11" in label
assert "устарели" in label
assert view.snapshot_freshness.cget("text_color") == Theme.STATUS_WARNING
finally:
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 not hasattr(view, "realtime")
assert view.route_diagram.provider_slots[0].winfo_manager() == "place"
finally:
view.destroy()
@pytest.mark.unit
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
snapshot = QuotaSnapshot(
account_id="account-1",
provider="antigravity",
buckets=[QuotaBucket(id="b", display_name="Gemini 5h")],
unavailable_reason="Авторизация недоступна",
)
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

@ -1,84 +0,0 @@
"""Regression checks for the Hermes Hub UI design-system foundation."""
from __future__ import annotations
import inspect
import typing
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import components
from antigravity_provider.router.ui.theme import Theme
@pytest.mark.unit
def test_required_component_library_is_available() -> None:
required = {
"HubCard",
"SectionHeader",
"StatusBadge",
"PlanBadge",
"ProviderBadge",
"QuotaBar",
"QuotaBucketWidget",
"AccountCardWidget",
"AgentCardWidget",
"RouteTargetWidget",
"EmptyState",
"SearchField",
"FilterButton",
"ActionButton",
"IconButton",
"ConfirmDialog",
"Toast",
}
assert required <= set(vars(components))
@pytest.mark.unit
def test_brand_gold_is_not_used_as_healthy_status() -> None:
assert Theme.COLOR_BRAND == Theme.ACCENT
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
assert Theme.COLOR_POSITIVE != Theme.COLOR_BRAND
assert components._semantic_color("healthy") == Theme.COLOR_POSITIVE
assert components._semantic_color("reserve") == Theme.COLOR_CAUTION
assert components._semantic_color("error") == Theme.COLOR_NEGATIVE
assert components._semantic_color("unknown") == Theme.COLOR_NEUTRAL
@pytest.mark.unit
def test_layout_and_typography_tokens_are_centralized() -> None:
assert Theme.PAGE_PAD_X in {Theme.SPACE_MD, Theme.SPACE_LG, Theme.SPACE_XL}
assert Theme.CARD_PAD_X in {Theme.SPACE_SM, Theme.SPACE_MD, Theme.SPACE_LG}
assert Theme.HEIGHT_INPUT == Theme.HEIGHT_BTN_MD - 2
assert Theme.font_title() == Theme.font_title_page()
assert Theme.font_title_page()[1] > Theme.font_heading()[1] > Theme.font_body()[1]
@pytest.mark.unit
def test_quota_bucket_has_stable_key_and_in_place_update_api() -> None:
signature = inspect.signature(components.QuotaBucketWidget.__init__)
assert "bucket_key" in signature.parameters
assert callable(getattr(components.QuotaBucketWidget, "update_bucket"))
@pytest.mark.unit
def test_unknown_quota_is_supported_explicitly() -> None:
signature = inspect.signature(components.QuotaBar.set_value)
annotation = signature.parameters["value"].annotation
# inspect resolves the real typing object here (components.py does not use
# `from __future__ import annotations`), so compare types, not source text.
assert type(None) in typing.get_args(annotation), (
"QuotaBar.set_value must accept None so an unknown quota can be rendered "
f"as such instead of a fabricated number; got {annotation!r}"
)
@pytest.mark.unit
def test_long_identity_is_ellipsized_without_losing_source_value() -> None:
identity = "very.long.account.identity.for.daily.operations@example.enterprise"
shortened = components.ellipsize_text(identity, 28)
assert len(shortened) == 28
assert shortened.endswith("")
assert identity.startswith(shortened[:-1])

View file

@ -1,147 +0,0 @@
"""Acceptance tests for the approved three-theme mockup redesign."""
from __future__ import annotations
import ast
from pathlib import Path
import re
from types import SimpleNamespace
import pytest
pytest.importorskip("customtkinter")
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")
def ui_root(tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
yield root
root.destroy()
@pytest.mark.unit
def test_every_restored_account_handler_has_a_ui_trigger() -> None:
required = {"test", "set_main", "set_orchestrator", "assign_role"}
configured = {action for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS}
assert configured == required
app_path = Path("src/antigravity_provider/router/hermes_hub_app.py")
tree = ast.parse(app_path.read_text(encoding="utf-8"))
handled = {
node.comparators[0].value
for node in ast.walk(tree)
if isinstance(node, ast.Compare)
and isinstance(node.left, ast.Name)
and node.left.id == "action"
and len(node.ops) == 1
and isinstance(node.ops[0], ast.Eq)
and len(node.comparators) == 1
and isinstance(node.comparators[0], ast.Constant)
and isinstance(node.comparators[0].value, str)
}
assert required <= handled
@pytest.mark.unit
def test_three_schemes_have_identical_token_coverage() -> None:
assert set(Theme.PALETTES) == {"dark", "hybrid", "light"}
token_sets = [set(palette) for palette in Theme.PALETTES.values()]
assert token_sets[0] == token_sets[1] == token_sets[2]
backgrounds = {palette["BG_WINDOW"] for palette in Theme.PALETTES.values()}
assert len(backgrounds) == 3
@pytest.mark.unit
def test_ui_colors_are_centralized_in_theme_tokens() -> None:
roots = [
Path("src/antigravity_provider/router/ui"),
Path("src/antigravity_provider/router/hermes_hub_app.py"),
]
offenders = []
for root in roots:
files = [root] if root.is_file() else list(root.rglob("*.py"))
for file in files:
if file.name == "theme.py":
continue
if re.search(r"#[0-9A-Fa-f]{3,8}", file.read_text(encoding="utf-8")):
offenders.append(str(file))
assert offenders == []
@pytest.mark.unit
def test_approved_mockup_numbers_are_not_shipped_as_placeholders() -> None:
source_files = list(Path("src/antigravity_provider/router/ui").rglob("*.py"))
source_files.append(Path("src/antigravity_provider/router/hermes_hub_app.py"))
source = "\n".join(file.read_text(encoding="utf-8") for file in source_files)
for fictional_value in ("78%", "09:0021:00", "842 rps", "99.98%", "42 Мбит/с"):
assert fictional_value not in source
@pytest.mark.unit
def test_approved_logo_and_consistent_sidebar_icon_system_are_wired() -> None:
assert Path("assets/branding/logo/logo_approved.png").is_file()
assets_source = Path("src/antigravity_provider/router/ui/assets.py").read_text(encoding="utf-8")
app_source = Path("src/antigravity_provider/router/hermes_hub_app.py").read_text(encoding="utf-8")
assert 'self.logo_dir / "logo_approved.png"' in assets_source
assert "def get_nav_icon" in assets_source
assert "get_nav_icon(icon, size=19)" in app_source
@pytest.mark.unit
def test_apply_scheme_updates_shared_semantic_aliases() -> None:
original = Theme.current_scheme
try:
for scheme in Theme.SCHEMES:
assert Theme.apply_scheme(scheme) == scheme
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
assert Theme.COLOR_CAUTION == Theme.STATUS_WARNING
assert Theme.COLOR_BRAND == Theme.ACCENT
finally:
Theme.apply_scheme(original)
@pytest.mark.ui
def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
calls = []
card = AccountCardWidget(
ui_root,
"profile-1",
"user@example.test",
"OpenAI Codex",
on_action=lambda action, profile: calls.append((action, profile.profile_id)),
)
card.profile_model = SimpleNamespace(profile_id="profile-1")
try:
card.pack()
for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS:
card.action_buttons[action].invoke()
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={"manager": policy}))
monkeypatch.setattr(
app_module.AutoAssigner,
"assign_profile_to_role",
lambda *_args, **_kwargs: (False, "Профиль не найден"),
)
ui_root._show_account_action_result = lambda *_args: None
modal = app_module.HermesHubApp._open_assign_role_modal(ui_root, "missing", "Claude")
try:
modal.save_button.invoke()
modal.update_idletasks()
assert modal.winfo_exists()
assert "Профиль не найден" in modal.result_label.cget("text")
finally:
modal.destroy()

View file

@ -1,191 +0,0 @@
"""Acceptance coverage for the phase 26 snapshot-driven UI."""
from __future__ import annotations
import time
from dataclasses import replace
from pathlib import Path
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import AccountCardWidget
from antigravity_provider.router.ui.views.accounts_view import AccountsView
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
from antigravity_provider.router.ui.views.routing_view import RoutingView
from antigravity_provider.router.ui.views.team_view import TeamView
from antigravity_provider.router.unified_health import ProfileViewModel, SystemReadiness
@pytest.fixture(scope="module")
def ui_root(tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
yield root
root.destroy()
def _profile(index: int) -> ProfileViewModel:
return ProfileViewModel(
profile_id=f"account-{index}",
display_name=f"Worker {index}",
account_identity=f"user-{index}@example.test",
provider="antigravity",
provider_display_name="Google Antigravity",
assigned_roles=["coder" if index else "manager"],
primary_role="coder" if index else "manager",
is_main_account=index == 0,
is_main_orchestrator=index == 0,
auth_state="AUTHENTICATED",
health_state="healthy",
health_label_ru="Работает",
model_states={},
cooldown_remaining_sec=0,
last_checked_at="12:00:00",
enabled=True,
is_cold_spare=False,
is_empty_slot=False,
email=f"mail-{index}@example.test",
plan="PRO",
plan_code="PRO",
preferred_models=["gemini-2.5-pro"],
)
def _quota(profile_id: str, remaining: float | None = None) -> QuotaSnapshot:
return QuotaSnapshot(
account_id=profile_id,
provider="antigravity",
buckets=[
QuotaBucket(
id="antigravity.gemini.5h",
display_name="Gemini 5h",
model_family="gemini",
remaining_percent=remaining,
period="5h",
)
],
source="baseline",
)
def _snapshot(count: int = 50, changed_remaining: float | None = None) -> HubSnapshot:
profiles = [_profile(index) for index in range(count)]
quotas = {
profile.profile_id: _quota(
profile.profile_id,
changed_remaining if profile.profile_id == "account-0" else None,
)
for profile in profiles
}
return HubSnapshot(
generation=1 if changed_remaining is None else 2,
seq=1 if changed_remaining is None else 2,
timestamp=time.time(),
profiles_by_provider={"antigravity": profiles},
all_profiles={profile.profile_id: profile for profile in profiles},
readiness=SystemReadiness(
state="healthy",
title_ru="Система готова",
summary_ru="Все назначенные роли доступны",
roles_ready_count=6,
total_roles=6,
accounts_connected_count=count,
total_accounts=count,
providers_ready_count=1,
total_providers=1,
),
agents=[],
providers=[],
routing={},
quotas=quotas,
)
@pytest.mark.unit
def test_identity_priority_and_plan_badge_suppression() -> None:
profile = _profile(1)
assert AccountCardWidget.resolve_identity(profile) == profile.email
assert "PlanBadge" not in Path("src/antigravity_provider/router/ui/views/accounts_view.py").read_text(
encoding="utf-8"
)
@pytest.mark.ui
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
view = AccountsView(ui_root)
try:
view.pack(fill="both", expand=True)
view.update_data(_snapshot())
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()
after = view.render_stats()
assert len(view._cards) == 50
assert before["cards_created"] == after["cards_created"] == 50
assert before["cards_destroyed"] == after["cards_destroyed"] == 0
assert before["quota_widgets_created"] == after["quota_widgets_created"] == 50
assert before["quota_widgets_destroyed"] == after["quota_widgets_destroyed"] == 0
assert {key: id(card) for key, card in view._cards.items()} == card_ids
bucket = view._cards["account-0"]._quota_widgets["antigravity.gemini.5h"]
assert "оценка" in bucket.title.cget("text")
finally:
view.destroy()
@pytest.mark.ui
def test_removing_account_destroys_only_its_card_and_views_accept_snapshot(ui_root) -> None:
children = []
try:
view = AccountsView(ui_root)
children.append(view)
first = _snapshot(3)
view.update_data(first)
retained = id(view._cards["account-1"])
profiles = first.profiles_by_provider["antigravity"][1:]
second = replace(
first,
generation=2,
all_profiles={profile.profile_id: profile for profile in profiles},
profiles_by_provider={"antigravity": profiles},
quotas={key: value for key, value in first.quotas.items() if key != "account-0"},
)
view.update_data(second)
assert "account-0" not in view._cards
assert id(view._cards["account-1"]) == retained
assert view.render_stats()["cards_destroyed"] == 1
for child in (DashboardView(ui_root), TeamView(ui_root), RoutingView(ui_root)):
children.append(child)
child.update_data(second)
ui_root.update_idletasks()
finally:
for child in children:
child.destroy()
@pytest.mark.unit
def test_data_views_do_not_call_backend_services() -> None:
view_dir = Path("src/antigravity_provider/router/ui/views")
forbidden = ("HubStateStore", "scan_all(", "AccountQuotaService", "HermesRefreshScheduler", "EventLogService")
for name in (
"accounts_view.py",
"dashboard_view.py",
"team_view.py",
"routing_view.py",
"providers_view.py",
"health_view.py",
"logs_view.py",
):
source = (view_dir / name).read_text(encoding="utf-8")
assert not any(token in source for token in forbidden), f"{name} accesses backend directly"

View file

@ -7,9 +7,7 @@ import os
import tempfile import tempfile
import pytest import pytest
pytest.importorskip("customtkinter") from antigravity_provider.router.action_handler import do_test_profile, do_set_main, do_set_orchestrator
from antigravity_provider.router.hermes_hub_app import do_test_profile, do_set_main, do_set_orchestrator
from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.profile_manager import ProfileAuthManager

View file

@ -1,358 +0,0 @@
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 import action_handler
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 = {
"manager": SimpleNamespace(preferred_chain=["orch"]),
"developer-1": SimpleNamespace(preferred_chain=["coder"]),
"developer-2": SimpleNamespace(preferred_chain=["coder"]),
"code-reviewer": SimpleNamespace(preferred_chain=["coder"]),
"researcher": SimpleNamespace(preferred_chain=["coder"]),
"tester": SimpleNamespace(preferred_chain=["coder"]),
}
return SimpleNamespace(roles=roles, profiles=profiles)
def test_agent_catalog_uses_backend_cache_without_literal_fallback(monkeypatch):
from antigravity_provider.router.ui import model_catalog
service = SimpleNamespace(get_cached=lambda _provider: {"models": ["provider-model-a", "provider-model-b"]})
monkeypatch.setattr(model_catalog, "_service", lambda: service)
cached = model_catalog.get_cached_models("antigravity")
assert cached.models == ("provider-model-a", "provider-model-b")
def test_agent_catalog_empty_cache_is_explicit(monkeypatch):
from antigravity_provider.router.ui import model_catalog
monkeypatch.setattr(model_catalog, "_service", lambda: None)
cached = model_catalog.get_cached_models("antigravity")
assert cached.models == ()
assert "ещё не подключена" in cached.unavailable_reason
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["code-reviewer"].preferred_chain = ["ghost"]
graph = RoutingGraph(
nodes=[
GraphNode("manager", 0, 0),
GraphNode("developer-1", 1, 0),
GraphNode("code-reviewer", 2, 0),
],
edges=[
GraphEdge("manager", "developer-1"),
GraphEdge("developer-1", "manager"),
],
)
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("manager", "developer-1", "FALLBACK", "orch")
assert ok
assert calls == [("orch", "developer-1", 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("manager", 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_invokes_model_with_timeout_and_records_success(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
class Adapter:
@staticmethod
def invoke(profile, req, *args, **kwargs):
return {"choices": [{"message": {"content": "pong"}}]}
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": False})
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
def mock_mark_success(self, p, m):
self.marked = True
monkeypatch.setattr("antigravity_provider.router.health_tracker.HealthTracker.mark_success", mock_mark_success)
result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is True
assert "Авторизация подтверждена" in result["response"]
def test_profile_test_expired_credentials_fail_immediately(monkeypatch):
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": True})
result = action_handler.do_test_profile("antigravity", "connected")
assert result["success"] is False
assert "Авторизация истекла" in result["error"]
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={
"manager": SimpleNamespace(preferred_chain=["a", "b", "c"]),
"code-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("manager", ["c", "a"])
assert ok
assert config.roles["manager"].preferred_chain == ["c", "a"]
assert config.roles["code-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"
# Список моделей заполняется обнаружением у провайдера, а не литералом.
# Пока обнаружение не выполнено, профиль остаётся без моделей: профиль
# без списка честнее профиля с выдуманным. Раньше сюда подставлялось
# "grok-3", и по тому же образцу в конфигурацию владельца попал
# gemini-3.7-flash, которого у провайдера не существует.
assert config.profiles["grok-orch"].preferred_models == []
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

View file

@ -1,54 +0,0 @@
"""Manual screenshot harness for every tab in all approved color schemes."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
pytest.importorskip("customtkinter")
pytest.importorskip("PIL")
from PIL import ImageGrab
from antigravity_provider.router.hermes_hub_app import HermesHubApp
def capture_all(output_dir: Path) -> list[Path]:
output_dir.mkdir(parents=True, exist_ok=True)
app = HermesHubApp()
app.geometry("1366x768+20+20")
app.deiconify()
app.lift()
app.attributes("-topmost", True)
for _index in range(20):
app.update()
time.sleep(0.04)
captured = []
try:
for scheme in ("dark", "hybrid", "light"):
app._apply_theme(scheme)
for view_name, _label, _icon in app._nav_items:
app._show_view(view_name)
app.update_idletasks()
app.update()
time.sleep(0.08)
left = app.winfo_rootx()
top = app.winfo_rooty()
target = output_dir / f"{view_name}-{scheme}.png"
ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target)
captured.append(target)
finally:
app.attributes("-topmost", False)
app._shutting_down = True
app.destroy()
return captured
if __name__ == "__main__":
destination = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("artifacts/mockup-redesign")
for screenshot in capture_all(destination):
print(screenshot)

View file

@ -1,65 +0,0 @@
"""Мастер подключения обязан закрываться по кнопке «Завершить».
Дефект, ради которого написан файл: `_finish` вызывал
`EventLogService.log_event` метода, которого у сервиса нет. Под `pythonw`
консоли нет, трейсбек Tk уходил в никуда, и для пользователя кнопка
«Завершить подключение» просто не работала: окно оставалось открытым,
аккаунт в маршрутизацию не попадал.
"""
from __future__ import annotations
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
@pytest.fixture(scope="module")
def ui_root(tk_root):
app = ctk.CTkToplevel(tk_root)
app.withdraw()
yield app
try:
app.destroy()
except Exception:
pass
def _wizard(root, on_complete=None):
w = AddAccountWizard(root, on_complete=on_complete)
w.withdraw()
w.selected_provider = "antigravity"
w.target_slot = "ag-w2"
w.discovered_identity = "user@example.com"
return w
@pytest.mark.ui
def test_finish_closes_wizard_and_reports_result(ui_root):
seen = []
w = _wizard(ui_root, on_complete=seen.append)
w._finish()
assert w.winfo_exists() == 0, "окно мастера осталось открытым после «Завершить»"
assert seen == [
{"provider": "antigravity", "profile_id": "ag-w2", "identity": "user@example.com"}
]
@pytest.mark.ui
def test_finish_closes_even_if_callback_raises(ui_root):
"""Сбой в обработчике владельца не должен запирать пользователя в мастере."""
def boom(_result):
raise RuntimeError("обновление данных упало")
w = _wizard(ui_root, on_complete=boom)
w._finish()
assert w.winfo_exists() == 0

View file

@ -1,72 +0,0 @@
"""Startup contract for UI views — runs without customtkinter.
Guards the crash that took the app down on launch:
TeamView.__init__ forwarded its legacy ``app_state`` dict into
``update_data(snapshot)``. The guard there only handled ``None``, so ``{}``
slipped through and ``snapshot.readiness`` raised
``'dict' object has no attribute 'readiness'``. "Команда" is the default
view, so the failure happened before the window ever appeared.
These checks are static: they read the view sources rather than build widgets,
so they run in headless environments where the GUI toolkit is absent.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
VIEWS_DIR = Path(__file__).resolve().parent.parent / "src" / "antigravity_provider" / "router" / "ui" / "views"
def _view_files() -> list[Path]:
return sorted(VIEWS_DIR.glob("*_view.py"))
def _self_update_data_calls(tree: ast.AST, inside: str) -> list[ast.Call]:
"""Return self.update_data(...) calls made from the named method."""
calls: list[ast.Call] = []
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
for fn in (n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == inside):
for node in ast.walk(fn):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "update_data"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
):
calls.append(node)
return calls
@pytest.mark.unit
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
def test_constructor_does_not_forward_app_state_to_update_data(view_path: Path) -> None:
"""A view constructor must not pass its legacy app_state into update_data."""
tree = ast.parse(view_path.read_text(encoding="utf-8"))
for call in _self_update_data_calls(tree, inside="__init__"):
assert not call.args and not call.keywords, (
f"{view_path.name}: __init__ calls self.update_data(...) with an argument. "
"update_data expects a HubSnapshot; constructors hold app_state dicts. "
"Call self.update_data() and let it pull the current snapshot."
)
@pytest.mark.unit
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
def test_update_data_guard_rejects_non_snapshot(view_path: Path) -> None:
"""update_data must fall back on anything that is not a HubSnapshot, not just None."""
source = view_path.read_text(encoding="utf-8")
if "def update_data" not in source or "snapshot" not in source:
pytest.skip("view has no snapshot-driven update_data")
# Views are pure renderers now: they either fall back to the store or return
# early. Either way the guard must reject anything that is not a snapshot —
# `snapshot is None` alone lets a legacy app_state dict through, which is
# what crashed the app on launch.
assert "isinstance(snapshot, HubSnapshot)" in source, (
f"{view_path.name}: update_data does not guard with isinstance(snapshot, HubSnapshot). "
"A legacy dict would pass a `snapshot is None` check and then fail on attribute access."
)